contacts = new ArrayList<>();
private @Nullable Boolean termsOfServiceAgreed;
private @Nullable Boolean onlyExisting;
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.
*
* A contact URI may be e.g. an email address or a phone number. It depends on the CA
* what kind of contact URIs are accepted, and how many must be provided as minimum.
*
* @param contact
* Contact URI
* @return itself
*/
public AccountBuilder addContact(URI contact) {
AcmeUtils.validateContact(contact);
contacts.add(contact);
return this;
}
/**
* Add a contact address to the list of contacts.
*
* This is a convenience call for {@link #addContact(URI)}.
*
* @param contact
* Contact URI as string
* @return itself
* @throws IllegalArgumentException
* if there is a syntax error in the URI string
*/
public AccountBuilder addContact(String contact) {
addContact(URI.create(contact));
return this;
}
/**
* Add an email address to the list of contacts.
*
* This is a convenience call for {@link #addContact(String)} that doesn't require
* to prepend the "mailto" scheme to an email address.
*
* @param email
* Contact email without "mailto" scheme (e.g. test@gmail.com)
* @return itself
* @throws IllegalArgumentException
* if there is a syntax error in the URI string
*/
public AccountBuilder addEmail(String email) {
if (email.startsWith("mailto:")) {
addContact(email);
} else {
addContact("mailto:" + email);
}
return this;
}
/**
* Documents that the user has agreed to the terms of service.
*
* If the CA requires the user to agree to the terms of service, it is your
* responsibility to present them to the user, and actively ask for their agreement. A
* link to the terms of service is provided via
* {@code session.getMetadata().getTermsOfService()}.
*
* @return itself
*/
public AccountBuilder agreeToTermsOfService() {
this.termsOfServiceAgreed = true;
return this;
}
/**
* Signals that only an existing account should be returned. The server will not
* create a new account if the key is not known.
*
* If you have lost your account's location URL, but still have your account's key
* pair, you can register your account again with the same key, and use
* {@link #onlyExisting()} to make sure that your existing account is returned. If
* your key is unknown to the server, an error is thrown once the account is to be
* created.
*
* @return itself
*/
public AccountBuilder onlyExisting() {
this.onlyExisting = true;
return this;
}
/**
* Sets the {@link KeyPair} to be used for this account.
*
* Only the public key of the pair is sent to the server for registration. acme4j will
* never send the private key part.
*
* Make sure to store your key pair safely after registration! There is no automatic
* way to regain access to your account if the key pair is lost.
*
* @param keyPair
* Account's {@link KeyPair}
* @return itself
*/
public AccountBuilder useKeyPair(KeyPair keyPair) {
this.keyPair = requireNonNull(keyPair, "keyPair");
return this;
}
/**
* Sets a Key Identifier and MAC key provided by the CA. Use this if your CA requires
* an individual account identification (e.g. your customer number) and a shared
* secret for registration. See the documentation of your CA about how to retrieve the
* key identifier and MAC key.
*
* @param kid
* Key Identifier
* @param macKey
* MAC key
* @return itself
* @see #withKeyIdentifier(String, String)
*/
public AccountBuilder withKeyIdentifier(String kid, SecretKey macKey) {
if (kid != null && kid.isEmpty()) {
throw new IllegalArgumentException("kid must not be empty");
}
this.macKey = requireNonNull(macKey, "macKey");
this.keyIdentifier = kid;
return this;
}
/**
* Sets a Key Identifier and MAC key provided by the CA. Use this if your CA requires
* an individual account identification (e.g. your customer number) and a shared
* secret for registration. See the documentation of your CA about how to retrieve the
* key identifier and MAC key.
*
* This is a convenience call of {@link #withKeyIdentifier(String, SecretKey)} that
* accepts a base64url encoded MAC key, so both parameters can be passed in as
* strings.
*
* @param kid
* Key Identifier
* @param encodedMacKey
* Base64url encoded MAC key.
* @return itself
* @see #withKeyIdentifier(String, SecretKey)
*/
public AccountBuilder withKeyIdentifier(String kid, String encodedMacKey) {
var encodedKey = AcmeUtils.base64UrlDecode(requireNonNull(encodedMacKey, "encodedMacKey"));
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.
*
* Use this method to finally create your account with the given parameters. Do not
* use the {@link AccountBuilder} after invoking this method.
*
* @param session
* {@link Session} to be used for registration
* @return {@link Account} referring to the new account
* @see #createLogin(Session)
*/
public Account create(Session session) throws AcmeException {
return createLogin(session).getAccount();
}
/**
* Creates a new account.
*
* This method is identical to {@link #create(Session)}, but returns a {@link Login}
* that is ready to be used.
*
* @param session
* {@link Session} to be used for registration
* @return {@link Login} referring to the new account
*/
public Login createLogin(Session session) throws AcmeException {
requireNonNull(session, "session");
if (keyPair == null) {
throw new IllegalStateException("Use AccountBuilder.useKeyPair() to set the account's key pair.");
}
LOG.debug("create");
try (var conn = session.connect()) {
var resourceUrl = session.resourceUrl(Resource.NEW_ACCOUNT);
var claims = new JSONBuilder();
if (!contacts.isEmpty()) {
claims.put("contact", contacts);
}
if (termsOfServiceAgreed != null) {
claims.put("termsOfServiceAgreed", termsOfServiceAgreed);
}
if (keyIdentifier != null && macKey != null) {
var algorithm = Optional.ofNullable(macAlgorithm)
.or(session.provider()::getProposedEabMacAlgorithm)
.orElseGet(() -> macKeyAlgorithm(macKey));
claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding(
keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl));
}
if (onlyExisting != null) {
claims.put("onlyReturnExisting", onlyExisting);
}
conn.sendSignedRequest(resourceUrl, claims, session, (url, payload, nonce) ->
JoseUtils.createJoseRequest(url, keyPair, payload, nonce, null));
var login = new Login(conn.getLocation(), keyPair, session);
login.getAccount().setJSON(conn.readJsonResponse());
return login;
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import java.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.toolbox.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* An extension of {@link AcmeResource} that also contains the current state of a resource
* as JSON document. If the current state is not present, this class takes care of
* 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}.
*
* @param login
* {@link Login} the resource is bound with
* @param location
* Location {@link URL} of this resource
*/
protected AcmeJsonResource(Login login, URL location) {
super(login, location);
}
/**
* Returns the JSON representation of the resource data.
*
* If there is no data, {@link #fetch()} is invoked to fetch it from the server.
*
* This method can be used to read proprietary data from the resources.
*
* @return Resource data, as {@link JSON}.
* @throws AcmeLazyLoadingException
* if an {@link AcmeException} occured while fetching the current state from
* the server.
*/
public JSON getJSON() {
if (data == null) {
try {
fetch();
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(this, ex);
}
}
return Objects.requireNonNull(data);
}
/**
* Sets the JSON representation of the resource data.
*
* @param data
* New {@link JSON} data, must not be {@code null}.
*/
protected void setJSON(JSON data) {
invalidate();
this.data = Objects.requireNonNull(data, "data");
}
/**
* 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 #fetch()}
* to fetch the current state from the server.
*/
protected boolean isValid() {
return data != null;
}
/**
* Invalidates the state of this resource. Enforces a {@link #fetch()} when
* {@link #getJSON()} is invoked.
*
* Subclasses can override this method to purge internal caches that are based on the
* JSON structure. Remember to invoke {@code super.invalidate()}!
*/
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.
* @since 3.2.0
*/
public Optional fetch() throws AcmeException {
var resourceType = getClass().getSimpleName();
LOG.debug("update {}", resourceType);
try (var conn = getSession().connect()) {
conn.sendSignedPostAsGetRequest(getLocation(), getLogin());
setJSON(conn.readJsonResponse());
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.
*
* 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 getRetryAfter() {
return Optional.ofNullable(retryAfter);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/AcmeResource.java
================================================
/*
* 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;
import java.io.Serial;
import java.io.Serializable;
import java.net.URL;
import java.util.Objects;
import edu.umd.cs.findbugs.annotations.Nullable;
/**
* This is the root class of all ACME resources (like accounts, orders, certificates).
* Every resource is identified by its location URL.
*
* This class also takes care for proper serialization and de-serialization of the
* resource. After de-serialization, the resource must be bound to a {@link Login} again,
* using {@link #rebind(Login)}.
*/
public abstract class AcmeResource implements Serializable {
@Serial
private static final long serialVersionUID = -7930580802257379731L;
private transient @Nullable Login login;
private final URL location;
/**
* Create a new {@link AcmeResource}.
*
* @param login
* {@link Login} the resource is bound with
* @param location
* Location {@link URL} of this resource
*/
protected AcmeResource(Login login, URL location) {
this.location = Objects.requireNonNull(location, "location");
rebind(login);
}
/**
* Gets the {@link Login} this resource is bound with.
*/
protected Login getLogin() {
if (login == null) {
throw new IllegalStateException("Use rebind() for binding this object to a login.");
}
return login;
}
/**
* Gets the {@link Session} this resource is bound with.
*/
protected Session getSession() {
return getLogin().getSession();
}
/**
* Rebinds this resource to a {@link Login}.
*
* Logins are not serialized, because they contain volatile session data and also a
* private key. After de-serialization of an {@link AcmeResource}, use this method to
* rebind it to a {@link Login}.
*
* @param login
* {@link Login} to bind this resource to
*/
public void rebind(Login login) {
if (this.login != null) {
throw new IllegalStateException("Resource is already bound to a login");
}
this.login = Objects.requireNonNull(login, "login");
}
/**
* Gets the resource's location.
*/
public URL getLocation() {
return location;
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.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;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON.Value;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Represents an authorization request at the ACME server.
*/
public class Authorization extends AcmeJsonResource implements PollableResource {
@Serial
private static final long serialVersionUID = -3116928998379417741L;
private static final Logger LOG = LoggerFactory.getLogger(Authorization.class);
protected Authorization(Login login, URL location) {
super(login, location);
}
/**
* Gets the {@link Identifier} to be authorized.
*
* For wildcard domain orders, the domain itself (without wildcard prefix) is returned
* here. To find out if this {@link Authorization} is related to a wildcard domain
* order, check the {@link #isWildcard()} method.
*
* @since 2.3
*/
public Identifier getIdentifier() {
return getJSON().get("identifier").asIdentifier();
}
/**
* Gets the authorization status.
*
* Possible values are: {@link Status#PENDING}, {@link Status#VALID},
* {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED},
* {@link Status#REVOKED}.
*/
@Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
/**
* Gets the expiry date of the authorization, if set by the server.
*/
public Optional getExpires() {
return getJSON().get("expires")
.map(Value::asString)
.map(AcmeUtils::parseTimestamp);
}
/**
* Returns {@code true} if this {@link Authorization} is related to a wildcard domain,
* {@code false} otherwise.
*/
public boolean isWildcard() {
return getJSON().get("wildcard")
.map(Value::asBoolean)
.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.
*/
public List getChallenges() {
var login = getLogin();
return getJSON().get("challenges")
.asArray()
.stream()
.map(Value::asObject)
.map(login::createChallenge)
.collect(toUnmodifiableList());
}
/**
* Finds a {@link Challenge} of the given type. Responding to this {@link Challenge}
* is sufficient for authorization.
*
* {@link Authorization#findChallenge(Class)} should be preferred, as this variant
* is not type safe.
*
* @param type
* Challenge name (e.g. "http-01")
* @return {@link Challenge} matching that name, or empty if there is no such
* challenge, or if the challenge alone is not sufficient for authorization.
* @throws ClassCastException
* if the type does not match the expected Challenge class type
*/
@SuppressWarnings("unchecked")
public Optional findChallenge(final String type) {
return (Optional) getChallenges().stream()
.filter(ch -> type.equals(ch.getType()))
.reduce((a, b) -> {
throw new AcmeProtocolException("Found more than one challenge of type " + type);
});
}
/**
* Finds a {@link Challenge} of the given class type. Responding to this {@link
* Challenge} is sufficient for authorization.
*
* @param type
* Challenge type (e.g. "Http01Challenge.class")
* @return {@link Challenge} of that type, or empty if there is no such
* challenge, or if the challenge alone is not sufficient for authorization.
* @since 2.8
*/
public Optional findChallenge(Class type) {
return getChallenges().stream()
.filter(type::isInstance)
.map(type::cast)
.reduce((a, b) -> {
throw new AcmeProtocolException("Found more than one challenge of type " + type.getName());
});
}
/**
* Waits until the authorization is completed.
*
* It is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
*
* 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}.
*/
public void deactivate() throws AcmeException {
LOG.debug("deactivate");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
claims.put("status", "deactivated");
conn.sendSignedRequest(getLocation(), claims, getLogin());
setJSON(conn.readJsonResponse());
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Certificate.java
================================================
/*
* 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;
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.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Represents an issued certificate and its certificate chain.
*
* A certificate is immutable once it is issued. For renewal, a new certificate must be
* ordered.
*/
public class Certificate extends AcmeResource {
@Serial
private static final long serialVersionUID = 7381527770159084201L;
private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
private @Nullable List certChain;
private @Nullable Collection alternates;
private transient @Nullable RenewalInfo renewalInfo = null;
private transient @Nullable List alternateCerts = null;
protected Certificate(Login login, URL certUrl) {
super(login, certUrl);
}
/**
* Downloads the certificate chain.
*
* The certificate is downloaded lazily by the other methods. Usually there is no need
* to invoke this method, unless the download is to be enforced. If the certificate
* has been downloaded already, nothing will happen.
*
* @throws AcmeException
* if the certificate could not be downloaded
*/
public void download() throws AcmeException {
if (certChain == null) {
LOG.debug("download");
try (var conn = getSession().connect()) {
conn.sendCertificateRequest(getLocation(), getLogin());
alternates = conn.getLinks("alternate");
certChain = conn.readCertificates();
}
}
}
/**
* Returns the created certificate.
*
* @return The created end-entity {@link X509Certificate} without issuer chain.
*/
public X509Certificate getCertificate() {
lazyDownload();
return requireNonNull(certChain).get(0);
}
/**
* Returns the created certificate and issuer chain.
*
* @return The created end-entity {@link X509Certificate} and issuer chain. The first
* certificate is always the end-entity certificate, followed by the
* intermediate certificates required to build a path to a trusted root.
*/
public List getCertificateChain() {
lazyDownload();
return unmodifiableList(requireNonNull(certChain));
}
/**
* Returns URLs to alternate certificate chains.
*
* @return Alternate certificate chains, or empty if there are none.
*/
public List getAlternates() {
lazyDownload();
return requireNonNull(alternates).stream().collect(toUnmodifiableList());
}
/**
* Returns alternate certificate chains, if available.
*
* @return Alternate certificate chains, or empty if there are none.
* @since 2.11
*/
public List getAlternateCertificates() {
if (alternateCerts == null) {
var login = getLogin();
alternateCerts = getAlternates().stream()
.map(login::bindCertificate)
.collect(toList());
}
return unmodifiableList(alternateCerts);
}
/**
* Checks if this certificate was issued by the given issuer name.
*
* @param issuer
* Issuer name to check against, case-sensitive
* @return {@code true} if this issuer name was found in the certificate chain as
* issuer, {@code false} otherwise.
* @since 3.0.0
*/
public boolean isIssuedBy(String issuer) {
var issuerCn = "CN=" + issuer;
return getCertificateChain().stream()
.map(X509Certificate::getIssuerX500Principal)
.map(Principal::getName)
.anyMatch(issuerCn::equals);
}
/**
* Finds a {@link Certificate} that was issued by the given issuer name.
*
* @param issuer
* Issuer name to check against, case-sensitive
* @return Certificate that was issued by that issuer, or {@code empty} if there was
* none. The returned {@link Certificate} may be this instance, or one of the
* {@link #getAlternateCertificates()} instances. If multiple certificates are issued
* by that issuer, the first one that was found is returned.
* @since 3.0.0
*/
public Optional findCertificate(String issuer) {
if (isIssuedBy(issuer)) {
return Optional.of(this);
}
return getAlternateCertificates().stream()
.filter(c -> c.isIssuedBy(issuer))
.findFirst();
}
/**
* Writes the certificate to the given writer. It is written in PEM format, with the
* end-entity cert coming first, followed by the intermediate certificates.
*
* @param out
* {@link Writer} to write to. The writer is not closed after use.
*/
public void writeCertificate(Writer out) throws IOException {
try {
for (var cert : getCertificateChain()) {
AcmeUtils.writeToPem(cert.getEncoded(), AcmeUtils.PemLabel.CERTIFICATE, out);
}
} catch (CertificateEncodingException ex) {
throw new IOException("Encoding error", ex);
}
}
/**
* Returns the location of the certificate's RenewalInfo. Empty if the CA does not
* provide this information.
*
* @since 3.0.0
*/
public Optional getRenewalInfoLocation() {
try {
return getSession().resourceUrlOptional(Resource.RENEWAL_INFO)
.map(baseUrl -> {
try {
var url = baseUrl.toExternalForm();
if (!url.endsWith("/")) {
url += '/';
}
url += getRenewalUniqueIdentifier(getCertificate());
return URI.create(url).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
}
});
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(this, ex);
}
}
/**
* Returns {@code true} if the CA provides renewal information.
*
* @since 3.0.0
*/
public boolean hasRenewalInfo() {
return getRenewalInfoLocation().isPresent();
}
/**
* Reads the RenewalInfo for this certificate.
*
* @return The {@link RenewalInfo} of this certificate.
* @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()
.map(getLogin()::bindRenewalInfo)
.orElseThrow(() -> new AcmeNotSupportedException("renewal-info"));
}
return renewalInfo;
}
/**
* Revokes this certificate.
*/
public void revoke() throws AcmeException {
revoke(null);
}
/**
* Revokes this certificate.
*
* @param reason
* {@link RevocationReason} stating the reason of the revocation that is
* used when generating OCSP responses and CRLs. {@code null} to give no
* reason.
* @see #revoke(Login, X509Certificate, RevocationReason)
* @see #revoke(Session, KeyPair, X509Certificate, RevocationReason)
*/
public void revoke(@Nullable RevocationReason reason) throws AcmeException {
revoke(getLogin(), getCertificate(), reason);
}
/**
* Revoke a certificate.
*
* Use this method if the certificate's location is unknown, so you cannot regenerate
* a {@link Certificate} instance. This method requires a {@link Login} to your
* account and the issued certificate.
*
* @param login
* {@link Login} to the account
* @param cert
* The {@link X509Certificate} to be revoked
* @param reason
* {@link RevocationReason} stating the reason of the revocation that is used
* when generating OCSP responses and CRLs. {@code null} to give no reason.
* @see #revoke(Session, KeyPair, X509Certificate, RevocationReason)
* @since 2.6
*/
public static void revoke(Login login, X509Certificate cert, @Nullable RevocationReason reason)
throws AcmeException {
LOG.debug("revoke");
var session = login.getSession();
var resUrl = session.resourceUrl(Resource.REVOKE_CERT);
try (var conn = session.connect()) {
var claims = new JSONBuilder();
claims.putBase64("certificate", cert.getEncoded());
if (reason != null) {
claims.put("reason", reason.getReasonCode());
}
conn.sendSignedRequest(resUrl, claims, login);
} catch (CertificateEncodingException ex) {
throw new AcmeProtocolException("Invalid certificate", ex);
}
}
/**
* Revoke a certificate.
*
* Use this method if the key pair of your account was lost (so you are unable to
* login into your account), but you still have the key pair of the affected domain
* and the issued certificate.
*
* @param session
* {@link Session} connected to the ACME server
* @param domainKeyPair
* Key pair the CSR was signed with
* @param cert
* The {@link X509Certificate} to be revoked
* @param reason
* {@link RevocationReason} stating the reason of the revocation that is used
* when generating OCSP responses and CRLs. {@code null} to give no reason.
* @see #revoke(Login, X509Certificate, RevocationReason)
*/
public static void revoke(Session session, KeyPair domainKeyPair, X509Certificate cert,
@Nullable RevocationReason reason) throws AcmeException {
LOG.debug("revoke using the domain key pair");
var resUrl = session.resourceUrl(Resource.REVOKE_CERT);
try (var conn = session.connect()) {
var claims = new JSONBuilder();
claims.putBase64("certificate", cert.getEncoded());
if (reason != null) {
claims.put("reason", reason.getReasonCode());
}
conn.sendSignedRequest(resUrl, claims, session, (url, payload, nonce) ->
JoseUtils.createJoseRequest(url, domainKeyPair, payload, nonce, null));
} catch (CertificateEncodingException ex) {
throw new AcmeProtocolException("Invalid certificate", ex);
}
}
/**
* Lazily downloads the certificate. Throws a runtime {@link AcmeLazyLoadingException}
* if the download failed.
*/
private void lazyDownload() {
try {
download();
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(this, ex);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Identifier.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.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;
/**
* Represents an identifier.
*
* The ACME protocol only defines the DNS identifier, which identifies a domain name.
* acme4j also supports IP identifiers.
*
* CAs, and other acme4j modules, may define further, proprietary identifier types.
*
* @since 2.3
*/
public class Identifier implements Serializable {
@Serial
private static final long serialVersionUID = -7777851842076362412L;
/**
* Type constant for DNS identifiers.
*/
public static final String TYPE_DNS = "dns";
/**
* Type constant for IP identifiers.
*
* @see RFC 8738
*/
public static final String TYPE_IP = "ip";
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 Map content = new TreeMap<>();
/**
* Creates a new {@link Identifier}.
*
* This is a generic constructor for identifiers. Refer to the documentation of your
* CA to find out about the accepted identifier types and values.
*
* Note that for DNS identifiers, no ASCII encoding of unicode domain takes place
* here. Use {@link #dns(String)} instead.
*
* @param type
* Identifier type
* @param value
* Identifier value
*/
public Identifier(String type, String value) {
content.put(KEY_TYPE, requireNonNull(type, KEY_TYPE));
content.put(KEY_VALUE, requireNonNull(value, KEY_VALUE));
}
/**
* Creates a new {@link Identifier} from the given {@link JSON} structure.
*
* @param json
* {@link JSON} containing the identifier data
*/
public Identifier(JSON json) {
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);
}
/**
* Creates a new DNS identifier for the given domain name.
*
* @param domain
* Domain name. Unicode domains are automatically ASCII encoded.
* @return New {@link Identifier}
*/
public static Identifier dns(String domain) {
return new Identifier(TYPE_DNS, toAce(domain));
}
/**
* Creates a new IP identifier for the given {@link InetAddress}.
*
* @param ip
* {@link InetAddress}
* @return New {@link Identifier}
*/
public static Identifier ip(InetAddress ip) {
return new Identifier(TYPE_IP, ip.getHostAddress());
}
/**
* Creates a new IP identifier for the given {@link InetAddress}.
*
* @param ip
* IP address as {@link String}
* @return New {@link Identifier}
* @since 2.7
*/
public static Identifier ip(String ip) {
try {
return ip(InetAddress.getByName(ip));
} catch (UnknownHostException ex) {
throw new IllegalArgumentException("Bad IP: " + ip, ex);
}
}
/**
* 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 content.get(KEY_TYPE).toString();
}
/**
* Returns the identifier value.
*/
public String getValue() {
return content.get(KEY_VALUE).toString();
}
/**
* Returns the domain name if this is a DNS identifier.
*
* @return Domain name. Unicode domains are ASCII encoded.
* @throws AcmeProtocolException
* if this is not a DNS identifier.
*/
public String getDomain() {
expectType(TYPE_DNS);
return getValue();
}
/**
* Returns the IP address if this is an IP identifier.
*
* @return {@link InetAddress}
* @throws AcmeProtocolException
* if this is not a DNS identifier.
*/
public InetAddress getIP() {
expectType(TYPE_IP);
try {
return InetAddress.getByName(getValue());
} catch (UnknownHostException ex) {
throw new AcmeProtocolException("bad ip identifier value", ex);
}
}
/**
* Returns the identifier as JSON map.
*/
public Map 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() {
if (content.size() == 2) {
return getType() + '=' + getValue();
}
return content.toString();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Identifier i)) {
return false;
}
return content.equals(i.content);
}
@Override
public int hashCode() {
return content.hashCode();
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Login.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.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.PublicKey;
import java.security.cert.X509Certificate;
import edu.umd.cs.findbugs.annotations.Nullable;
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;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtils;
/**
* A {@link Login} into an account.
*
* A login is bound to a {@link Session}. However, a {@link Session} can handle multiple
* logins in parallel.
*
* To create a login, you need to specify the location URI of the {@link Account}, and
* need to provide the {@link KeyPair} the account was created with. If the account's
* location URL is unknown, the account can be re-registered with the
* {@link AccountBuilder}, using {@link AccountBuilder#onlyExisting()} to make sure that
* no new account will be created. If the key pair was lost though, there is no automatic
* way to regain access to your account, and you have to contact your CA's support hotline
* for assistance.
*
* Note that {@link Login} objects are intentionally not serializable, as they contain a
* keypair and volatile data. On distributed systems, you can create a {@link Login} to
* the same account for every service instance.
*/
public class Login {
private final Session session;
private final Account account;
private KeyPair keyPair;
/**
* Creates a new {@link Login}.
*
* @param accountLocation
* Account location {@link URL}
* @param keyPair
* {@link KeyPair} of the account
* @param session
* {@link Session} to be used
*/
public Login(URL accountLocation, KeyPair keyPair, Session session) {
this.keyPair = requireNonNull(keyPair, "keyPair");
this.session = requireNonNull(session, "session");
this.account = new Account(this, requireNonNull(accountLocation, "accountLocation"));
}
/**
* Gets the {@link Session} that is used.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Session getSession() {
return session;
}
/**
* Gets the {@link PublicKey} of the ACME account.
*
* @since 5.0.0
*/
public PublicKey getPublicKey() {
return keyPair.getPublic();
}
/**
* Gets the {@link Account} that is bound to this login.
*
* @return {@link Account} bound to the login
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Account getAccount() {
return account;
}
/**
* Creates a new instance of an existing {@link Authorization} and binds it to this
* login.
*
* @param location
* Location of the Authorization
* @return {@link Authorization} bound to the login
*/
public Authorization bindAuthorization(URL location) {
return new Authorization(this, requireNonNull(location, "location"));
}
/**
* Creates a new instance of an existing {@link Certificate} and binds it to this
* login.
*
* @param location
* Location of the Certificate
* @return {@link Certificate} bound to the login
*/
public Certificate bindCertificate(URL location) {
return new Certificate(this, requireNonNull(location, "location"));
}
/**
* Creates a new instance of an existing {@link Order} and binds it to this login.
*
* @param location
* Location URL of the order
* @return {@link Order} bound to the login
*/
public Order bindOrder(URL location) {
return new Order(this, requireNonNull(location, "location"));
}
/**
* Creates a new instance of an existing {@link RenewalInfo} and binds it to this
* login.
*
* @param location
* Location URL of the renewal info
* @return {@link RenewalInfo} bound to the login
* @since 3.0.0
*/
public RenewalInfo bindRenewalInfo(URL location) {
return new RenewalInfo(this, requireNonNull(location, "location"));
}
/**
* Creates a new instance of an existing {@link 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.
*
* @param location
* Location URL of the challenge
* @return {@link Challenge} bound to the login
* @since 2.8
* @see #bindChallenge(URL, Class)
*/
public Challenge bindChallenge(URL location) {
try (var connect = session.connect()) {
connect.sendSignedPostAsGetRequest(location, this);
return createChallenge(connect.readJsonResponse());
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(Challenge.class, location, ex);
}
}
/**
* Creates a new instance of an existing {@link Challenge} and binds it to this
* login. Use this method if the resulting challenge type is known.
*
* @param location
* Location URL of the challenge
* @param type
* Expected challenge type
* @return Challenge bound to the login
* @throws AcmeProtocolException
* if the challenge found at the location does not match the expected
* challenge type.
* @since 2.12
*/
public C bindChallenge(URL location, Class type) {
var challenge = bindChallenge(location);
if (!type.isInstance(challenge)) {
throw new AcmeProtocolException("Challenge type " + challenge.getType()
+ " does not match requested class " + type);
}
return type.cast(challenge);
}
/**
* Creates a {@link Challenge} instance for the given challenge data.
*
* @param data
* Challenge JSON data
* @return {@link Challenge} instance
*/
public Challenge createChallenge(JSON data) {
var challenge = session.provider().createChallenge(this, data);
if (challenge == null) {
throw new AcmeProtocolException("Could not create challenge for: " + data);
}
return challenge;
}
/**
* Creates a builder for a new {@link Order}.
*
* @return {@link OrderBuilder} object
* @since 3.0.0
*/
public OrderBuilder newOrder() {
return new OrderBuilder(this);
}
/**
* Sets a different {@link KeyPair}. The new key pair is only used locally in this
* instance, but is not set on server side!
*/
protected void setKeyPair(KeyPair keyPair) {
this.keyPair = requireNonNull(keyPair, "keyPair");
}
/**
* Creates an ACME JOSE request. This method is meant for internal purposes only.
*
* @param url
* {@link URL} of the ACME call
* @param payload
* ACME JSON payload. If {@code null}, a POST-as-GET request is generated
* instead.
* @param nonce
* Nonce to be used. {@code null} if no nonce is to be used in the JOSE
* header.
* @return JSON structure of the JOSE request, ready to be sent.
* @since 5.0.0
*/
public JSONBuilder createJoseRequest(URL url, @Nullable JSONBuilder payload, @Nullable String nonce) {
return JoseUtils.createJoseRequest(url, keyPair, payload, nonce, getAccount().getLocation().toString());
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Metadata.java
================================================
/*
* 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;
import static java.util.stream.Collectors.toList;
import java.net.URI;
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;
import org.shredzone.acme4j.toolbox.JSON.Value;
/**
* A collection of metadata related to the CA provider.
*/
public class Metadata {
private final JSON meta;
/**
* Creates a new {@link Metadata} instance.
*
* @param meta
* JSON map of metadata
*/
public Metadata(JSON meta) {
this.meta = meta;
}
/**
* Returns an {@link URI} of the current terms of service, or empty if not available.
*/
public Optional getTermsOfService() {
return meta.get("termsOfService").map(Value::asURI);
}
/**
* Returns an {@link URL} of a website providing more information about the ACME
* server. Empty if not available.
*/
public Optional getWebsite() {
return meta.get("website").map(Value::asURL);
}
/**
* Returns a collection of hostnames, which the ACME server recognises as referring to
* itself for the purposes of CAA record validation. Empty if not available.
*/
public Collection getCaaIdentities() {
return meta.get("caaIdentities")
.asArray()
.stream()
.map(Value::asString)
.collect(toList());
}
/**
* Returns whether an external account is required by this CA.
*/
public boolean isExternalAccountRequired() {
return meta.get("externalAccountRequired").map(Value::asBoolean).orElse(false);
}
/**
* Returns whether the CA supports short-term auto-renewal of certificates.
*
* @since 2.3
*/
public boolean isAutoRenewalEnabled() {
return meta.get("auto-renewal").isPresent();
}
/**
* Returns the minimum acceptable value for the maximum validity of a certificate
* before auto-renewal.
*
* @since 2.3
* @throws AcmeNotSupportedException if the server does not support auto-renewal.
*/
public Duration getAutoRenewalMinLifetime() {
return meta.getFeature("auto-renewal")
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("min-lifetime")
.asDuration();
}
/**
* Returns the maximum delta between auto-renewal end date and auto-renewal start
* date.
*
* @since 2.3
* @throws AcmeNotSupportedException if the server does not support auto-renewal.
*/
public Duration getAutoRenewalMaxDuration() {
return meta.getFeature("auto-renewal")
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("max-duration")
.asDuration();
}
/**
* Returns whether the CA also allows to fetch STAR certificates via GET request.
*
* @since 2.6
* @throws AcmeNotSupportedException if the server does not support auto-renewal.
*/
public boolean isAutoRenewalGetAllowed() {
return meta.getFeature("auto-renewal").optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("allow-certificate-get")
.optional()
.map(Value::asBoolean)
.orElse(false);
}
/**
* Returns whether the CA supports the profile feature.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-ietf-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.
*
* 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-ietf-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-ietf-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 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.
*
* Empty if the profile is not allowed.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-ietf-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 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.
*/
public JSON getJSON() {
return meta;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Order.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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.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;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.util.CSRBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A representation of a certificate order at the CA.
*/
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 List authorizations = null;
protected Order(Login login, URL location) {
super(login, location);
}
/**
* Returns the current status of the order.
*
* Possible values are: {@link Status#PENDING}, {@link Status#READY},
* {@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();
}
/**
* Returns a {@link Problem} document with the reason if the order has failed.
*/
public Optional getError() {
return getJSON().get("error").map(v -> v.asProblem(getLocation()));
}
/**
* Gets the expiry date of the authorization, if set by the server.
*/
public Optional getExpires() {
return getJSON().get("expires").map(Value::asInstant);
}
/**
* Gets a list of {@link Identifier} that are connected to this order.
*
* @since 2.3
*/
public List getIdentifiers() {
return getJSON().get("identifiers")
.asArray()
.stream()
.map(Value::asIdentifier)
.toList();
}
/**
* Gets the "not before" date that was used for the order.
*/
public Optional getNotBefore() {
return getJSON().get("notBefore").map(Value::asInstant);
}
/**
* Gets the "not after" date that was used for the order.
*/
public Optional getNotAfter() {
return getJSON().get("notAfter").map(Value::asInstant);
}
/**
* Gets the {@link Authorization} that are required to fulfil this order, in no
* specific order.
*/
public List getAuthorizations() {
if (authorizations == null) {
var login = getLogin();
authorizations = getJSON().get("authorizations")
.asArray()
.stream()
.map(Value::asURL)
.map(login::bindAuthorization)
.collect(toList());
}
return unmodifiableList(authorizations);
}
/**
* Gets the location {@link URL} of where to send the finalization call to.
*
* For internal purposes. Use {@link #execute(byte[])} to finalize an order.
*/
public URL getFinalizeLocation() {
return getJSON().get("finalize").asURL();
}
/**
* Gets the {@link Certificate}.
*
* @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}.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Certificate getCertificate() {
if (certificate == null) {
certificate = getJSON().get("star-certificate")
.optional()
.or(() -> getJSON().get("certificate").optional())
.map(Value::asURL)
.map(getLogin()::bindCertificate)
.orElseThrow(() -> new IllegalStateException("Order is not completed"));
}
return certificate;
}
/**
* Returns whether this is a STAR certificate ({@code true}) or a standard certificate
* ({@code false}).
*
* @since 3.5.0
*/
public boolean isAutoRenewalCertificate() {
return getJSON().contains("star-certificate");
}
/**
* Finalizes the order.
*
* If the finalization was successful, the certificate is provided via
* {@link #getCertificate()}.
*
* Even though the ACME protocol uses the term "finalize an order", this method is
* called {@link #execute(KeyPair)} to avoid confusion with the problematic
* {@link Object#finalize()} method.
*
* @param domainKeyPair
* The {@link KeyPair} that is going to be certified. This is not
* your account's keypair!
* @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 {
execute(domainKeyPair, csrBuilder -> {});
}
/**
* Finalizes the order (see {@link #execute(KeyPair)}).
*
* This method also accepts a builderConsumer that can be used to add further details
* to the CSR (e.g. your organization). The identifiers (IPs, domain names, etc.) are
* automatically added to the CSR.
*
* @param domainKeyPair
* The {@link KeyPair} that is going to be used together with the certificate.
* This is not your account's keypair!
* @param builderConsumer
* {@link Consumer} that adds further details to the provided
* {@link CSRBuilder}.
* @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 builderConsumer) throws AcmeException {
try {
var csrBuilder = new CSRBuilder();
csrBuilder.addIdentifiers(getIdentifiers());
builderConsumer.accept(csrBuilder);
csrBuilder.sign(domainKeyPair);
execute(csrBuilder.getCSR());
} catch (IOException ex) {
throw new AcmeException("Failed to create CSR", ex);
}
}
/**
* Finalizes the order (see {@link #execute(KeyPair)}).
*
* This method receives a {@link PKCS10CertificationRequest} instance of a CSR that
* was generated externally. Use this method to gain full control over the content of
* the CSR. The CSR is not checked by acme4j, but just transported to the CA. It is
* your responsibility that it matches to the order.
*
* @param csr
* {@link PKCS10CertificationRequest} to be used for this order.
* @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 {
try {
execute(csr.getEncoded());
} catch (IOException ex) {
throw new AcmeException("Invalid CSR", ex);
}
}
/**
* Finalizes the order (see {@link #execute(KeyPair)}).
*
* This method receives a byte array containing an encoded CSR that was generated
* externally. Use this method to gain full control over the content of the CSR. The
* CSR is not checked by acme4j, but just transported to the CA. It is your
* responsibility that it matches to the order.
*
* @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");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
claims.putBase64("csr", csr);
conn.sendSignedRequest(getFinalizeLocation(), claims, getLogin());
}
invalidate();
}
/**
* Waits until the order is ready for finalization.
*
* 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}.
*
* 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.
*
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
*
* 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.
*
* @since 2.3
*/
public boolean isAutoRenewing() {
return getJSON().get("auto-renewal")
.optional()
.isPresent();
}
/**
* Returns the earliest date of validity of the first certificate issued.
*
* @since 2.3
* @throws AcmeNotSupportedException if auto-renewal is not supported
*/
public Optional getAutoRenewalStartDate() {
return getJSON().getFeature("auto-renewal")
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("start-date")
.optional()
.map(Value::asInstant);
}
/**
* Returns the latest date of validity of the last certificate issued.
*
* @since 2.3
* @throws AcmeNotSupportedException if auto-renewal is not supported
*/
public Instant getAutoRenewalEndDate() {
return getJSON().getFeature("auto-renewal")
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("end-date")
.asInstant();
}
/**
* Returns the maximum lifetime of each certificate.
*
* @since 2.3
* @throws AcmeNotSupportedException if auto-renewal is not supported
*/
public Duration getAutoRenewalLifetime() {
return getJSON().getFeature("auto-renewal")
.optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("lifetime")
.asDuration();
}
/**
* Returns the pre-date period of each certificate.
*
* @since 2.7
* @throws AcmeNotSupportedException if auto-renewal is not supported
*/
public Optional getAutoRenewalLifetimeAdjust() {
return getJSON().getFeature("auto-renewal")
.optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("lifetime-adjust")
.optional()
.map(Value::asDuration);
}
/**
* Returns {@code true} if STAR certificates from this order can also be fetched via
* GET requests.
*
* @since 2.6
*/
public boolean isAutoRenewalGetEnabled() {
return getJSON().getFeature("auto-renewal")
.optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.get("allow-certificate-get")
.optional()
.map(Value::asBoolean)
.orElse(false);
}
/**
* Cancels an auto-renewing order.
*
* @since 2.3
*/
public void cancelAutoRenewal() throws AcmeException {
if (!getSession().getMetadata().isAutoRenewalEnabled()) {
throw new AcmeNotSupportedException("auto-renewal");
}
LOG.debug("cancel");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
claims.put("status", "canceled");
conn.sendSignedRequest(getLocation(), claims, getLogin());
setJSON(conn.readJsonResponse());
}
}
/**
* 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;
authorizations = null;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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.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;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Start a new certificate {@link Order}.
*
* Use {@link Login#newOrder()} or {@link Account#newOrder()} to create a new
* {@link OrderBuilder} instance. Both methods are identical.
*/
public class OrderBuilder {
private static final Logger LOG = LoggerFactory.getLogger(OrderBuilder.class);
private final Login login;
private final Set 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}.
*
* @param login
* {@link Login} to bind with
*/
protected OrderBuilder(Login login) {
this.login = login;
}
/**
* Adds a domain name to the order.
*
* @param domain
* Name of a domain to be ordered. May be a wildcard domain if supported by
* the CA. IDN names are accepted and will be ACE encoded automatically.
* @return itself
*/
public OrderBuilder domain(String domain) {
return identifier(Identifier.dns(domain));
}
/**
* Adds domain names to the order.
*
* @param domains
* Collection of domain names to be ordered. May be wildcard domains if
* supported by the CA. IDN names are accepted and will be ACE encoded
* automatically.
* @return itself
*/
public OrderBuilder domains(String... domains) {
for (var domain : requireNonNull(domains, "domains")) {
domain(domain);
}
return this;
}
/**
* Adds a collection of domain names to the order.
*
* @param domains
* Collection of domain names to be ordered. May be wildcard domains if
* supported by the CA. IDN names are accepted and will be ACE encoded
* automatically.
* @return itself
*/
public OrderBuilder domains(Collection domains) {
requireNonNull(domains, "domains").forEach(this::domain);
return this;
}
/**
* Adds an {@link Identifier} to the order.
*
* @param identifier
* {@link Identifier} to be added to the order.
* @return itself
* @since 2.3
*/
public OrderBuilder identifier(Identifier identifier) {
identifierSet.add(requireNonNull(identifier, "identifier"));
return this;
}
/**
* Adds a collection of {@link Identifier} to the order.
*
* @param identifiers
* Collection of {@link Identifier} to be added to the order.
* @return itself
* @since 2.3
*/
public OrderBuilder identifiers(Collection identifiers) {
requireNonNull(identifiers, "identifiers").forEach(this::identifier);
return this;
}
/**
* Sets a "not before" date in the certificate. May be ignored by the CA.
*
* @param notBefore "not before" date
* @return itself
*/
public OrderBuilder notBefore(Instant notBefore) {
if (autoRenewal) {
throw new IllegalArgumentException("cannot combine notBefore with autoRenew");
}
this.notBefore = requireNonNull(notBefore, "notBefore");
return this;
}
/**
* Sets a "not after" date in the certificate. May be ignored by the CA.
*
* @param notAfter "not after" date
* @return itself
*/
public OrderBuilder notAfter(Instant notAfter) {
if (autoRenewal) {
throw new IllegalArgumentException("cannot combine notAfter with autoRenew");
}
this.notAfter = requireNonNull(notAfter, "notAfter");
return this;
}
/**
* Enables short-term automatic renewal of the certificate, if supported by the CA.
*
* Automatic renewals cannot be combined with {@link #notBefore(Instant)} or
* {@link #notAfter(Instant)}.
*
* @return itself
* @since 2.3
*/
public OrderBuilder autoRenewal() {
if (notBefore != null || notAfter != null) {
throw new IllegalArgumentException("cannot combine notBefore/notAfter with autoRenewal");
}
this.autoRenewal = true;
return this;
}
/**
* Sets the earliest date of validity of the first issued certificate. If not set,
* the start date is the earliest possible date.
*
* Implies {@link #autoRenewal()}.
*
* @param start
* Start date of validity
* @return itself
* @since 2.3
*/
public OrderBuilder autoRenewalStart(Instant start) {
autoRenewal();
this.autoRenewalStart = requireNonNull(start, "start");
return this;
}
/**
* Sets the latest date of validity of the last issued certificate. If not set, the
* CA's default is used.
*
* Implies {@link #autoRenewal()}.
*
* @param end
* End date of validity
* @return itself
* @see Metadata#getAutoRenewalMaxDuration()
* @since 2.3
*/
public OrderBuilder autoRenewalEnd(Instant end) {
autoRenewal();
this.autoRenewalEnd = requireNonNull(end, "end");
return this;
}
/**
* Sets the maximum validity period of each certificate. If not set, the CA's
* default is used.
*
* Implies {@link #autoRenewal()}.
*
* @param duration
* Duration of validity of each certificate
* @return itself
* @see Metadata#getAutoRenewalMinLifetime()
* @since 2.3
*/
public OrderBuilder autoRenewalLifetime(Duration duration) {
autoRenewal();
this.autoRenewalLifetime = requireNonNull(duration, "duration");
return this;
}
/**
* Sets the amount of pre-dating each certificate. If not set, the CA's
* default (0) is used.
*
* Implies {@link #autoRenewal()}.
*
* @param duration
* Duration of certificate pre-dating
* @return itself
* @since 2.7
*/
public OrderBuilder autoRenewalLifetimeAdjust(Duration duration) {
autoRenewal();
this.autoRenewalLifetimeAdjust = requireNonNull(duration, "duration");
return this;
}
/**
* Announces that the client wishes to fetch the auto-renewed certificate via GET
* request. If not used, the STAR certificate can only be fetched via POST-as-GET
* request. {@link Metadata#isAutoRenewalGetAllowed()} must return {@code true} in
* order for this option to work.
*
* This option is only needed if you plan to fetch the STAR certificate via other
* means than by using acme4j. acme4j is fetching certificates via POST-as-GET
* request.
*
* Implies {@link #autoRenewal()}.
*
* @return itself
* @since 2.6
*/
public OrderBuilder autoRenewalEnableGet() {
autoRenewal();
this.autoRenewalGet = true;
return this;
}
/**
* Notifies the CA of the desired profile of the ordered certificate.
*
* Optional, only supported if the CA supports profiles. However, in this case the
* client may include this field.
*
* @param profile
* Identifier of the desired profile
* @return itself
* @draft This method is currently based on RFC draft draft-ietf-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.
*
* Optional, only supported if the CA provides renewal information. However, in this
* case the client should 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.
*
* Optional, only supported if the CA provides renewal information. However, in this
* case the client should 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.
*
* Optional, only supported if the CA provides renewal information. However, in this
* case the client should 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.
*
* @return {@link Order} that was created
*/
public Order create() throws AcmeException {
if (identifierSet.isEmpty()) {
throw new IllegalArgumentException("At least one identifer is required");
}
var session = login.getSession();
if (autoRenewal && !session.getMetadata().isAutoRenewalEnabled()) {
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();
claims.array("identifiers", identifierSet.stream().map(Identifier::toMap).collect(toList()));
if (notBefore != null) {
claims.put("notBefore", notBefore);
}
if (notAfter != null) {
claims.put("notAfter", notAfter);
}
if (autoRenewal) {
var arClaims = claims.object("auto-renewal");
if (autoRenewalStart != null) {
arClaims.put("start-date", autoRenewalStart);
}
if (autoRenewalStart != null) {
arClaims.put("end-date", autoRenewalEnd);
}
if (autoRenewalLifetime != null) {
arClaims.put("lifetime", autoRenewalLifetime);
}
if (autoRenewalLifetimeAdjust != null) {
arClaims.put("lifetime-adjust", autoRenewalLifetimeAdjust);
}
if (autoRenewalGet) {
arClaims.put("allow-certificate-get", autoRenewalGet);
}
}
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());
order.setJSON(conn.readJsonResponse());
return order;
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java
================================================
/*
* 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.
*
* 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 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.
*
* This method is synchronous and blocks the current thread.
*
* 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 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");
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Problem.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import java.io.Serial;
import java.io.Serializable;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.Optional;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value;
/**
* A JSON problem. It contains further, machine- and human-readable details about the
* reason of an error or failure.
*
* @see RFC 7807
*/
public class Problem implements Serializable {
@Serial
private static final long serialVersionUID = -8418248862966754214L;
private final URL baseUrl;
private final JSON problemJson;
/**
* Creates a new {@link Problem} object.
*
* @param problem
* Problem as JSON structure
* @param baseUrl
* Document's base {@link URL} to resolve relative URIs against
*/
public Problem(JSON problem, URL baseUrl) {
this.problemJson = problem;
this.baseUrl = baseUrl;
}
/**
* Returns the problem type. It is always an absolute URI.
*/
public URI getType() {
return problemJson.get("type")
.map(Value::asString)
.map(it -> {
try {
return baseUrl.toURI().resolve(it);
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Bad base URL", ex);
}
})
.orElseThrow(() -> new AcmeProtocolException("Problem without type"));
}
/**
* Returns a short, human-readable summary of the problem. The text may be localized
* if supported by the server. Empty if the server did not provide a title.
*
* @see #toString()
*/
public Optional getTitle() {
return problemJson.get("title").map(Value::asString);
}
/**
* Returns a detailed and specific human-readable explanation of the problem. The
* text may be localized if supported by the server.
*
* @see #toString()
*/
public Optional getDetail() {
return problemJson.get("detail").map(Value::asString);
}
/**
* Returns a URI that identifies the specific occurence of the problem. It is always
* an absolute URI.
*/
public Optional getInstance() {
return problemJson.get("instance")
.map(Value::asString)
.map(it -> {
try {
return baseUrl.toURI().resolve(it);
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Bad base URL", ex);
}
});
}
/**
* Returns the {@link Identifier} this problem relates to.
*
* @since 2.3
*/
public Optional getIdentifier() {
return problemJson.get("identifier")
.optional()
.map(Value::asIdentifier);
}
/**
* Returns a list of sub-problems.
*/
public List getSubProblems() {
return problemJson.get("subproblems")
.asArray()
.stream()
.map(o -> o.asProblem(baseUrl))
.toList();
}
/**
* Returns the problem as {@link JSON} object, to access other, non-standard fields.
*
* @return Problem as {@link JSON} object
*/
public JSON asJSON() {
return problemJson;
}
/**
* Returns a human-readable description of the problem, that is as specific as
* possible. The description may be localized if supported by the server.
*
* If {@link #getSubProblems()} exist, they will be appended.
*
* Technically, it returns {@link #getDetail()}. If not set, {@link #getTitle()} is
* returned instead. As a last resort, {@link #getType()} is returned.
*/
@Override
public String toString() {
var sb = new StringBuilder();
if (getDetail().isPresent()) {
sb.append(getDetail().get());
} else if (getTitle().isPresent()) {
sb.append(getTitle().get());
} else {
sb.append(getType());
}
var subproblems = getSubProblems();
if (!subproblems.isEmpty()) {
sb.append(" (");
var first = true;
for (var sub : subproblems) {
if (!first) {
sb.append(" ‒ ");
}
sb.append(sub.toString());
first = false;
}
sb.append(')');
}
return sb.toString();
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import java.net.URL;
import java.time.Instant;
import java.time.temporal.TemporalAmount;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Renewal Information of a certificate.
*
* @since 3.0.0
*/
public class RenewalInfo extends AcmeJsonResource {
private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class);
protected RenewalInfo(Login login, URL location) {
super(login, location);
}
/**
* Returns the starting {@link Instant} of the time window the CA recommends for
* certificate renewal.
*/
public Instant getSuggestedWindowStart() {
return getJSON().get("suggestedWindow").asObject().get("start").asInstant();
}
/**
* Returns the ending {@link Instant} of the time window the CA recommends for
* certificate renewal.
*/
public Instant getSuggestedWindowEnd() {
return getJSON().get("suggestedWindow").asObject().get("end").asInstant();
}
/**
* An optional {@link URL} pointing to a page which may explain why the suggested
* renewal window is what it is.
*/
public Optional getExplanation() {
return getJSON().get("explanationURL").optional().map(Value::asURL);
}
/**
* Checks if the given {@link Instant} is before the suggested time window, so a
* certificate renewal is not required yet.
*
* @param instant
* {@link Instant} to check
* @return {@code true} if the {@link Instant} is before the time window, {@code
* false} otherwise.
*/
public boolean renewalIsNotRequired(Instant instant) {
assertValidTimeWindow();
return instant.isBefore(getSuggestedWindowStart());
}
/**
* Checks if the given {@link Instant} is within the suggested time window, and a
* certificate renewal is recommended.
*
* An {@link Instant} is deemed to be within the time window if it is equal to, or
* after {@link #getSuggestedWindowStart()}, and before {@link
* #getSuggestedWindowEnd()}.
*
* @param instant
* {@link Instant} to check
* @return {@code true} if the {@link Instant} is within the time window, {@code
* false} otherwise.
*/
public boolean renewalIsRecommended(Instant instant) {
assertValidTimeWindow();
return !instant.isBefore(getSuggestedWindowStart())
&& instant.isBefore(getSuggestedWindowEnd());
}
/**
* Checks if the given {@link Instant} is past the time window, and a certificate
* renewal is overdue.
*
* An {@link Instant} is deemed to be past the time window if it is equal to, or after
* {@link #getSuggestedWindowEnd()}.
*
* @param instant
* {@link Instant} to check
* @return {@code true} if the {@link Instant} is past the time window, {@code false}
* otherwise.
*/
public boolean renewalIsOverdue(Instant instant) {
assertValidTimeWindow();
return !instant.isBefore(getSuggestedWindowEnd());
}
/**
* Returns a proposed {@link Instant} when the certificate related to this
* {@link RenewalInfo} should be renewed.
*
* This method is useful for setting alarms for renewal cron jobs. As a parameter, the
* frequency of the cron job is set. The resulting {@link Instant} is guaranteed to be
* executed in time, considering the cron job intervals.
*
* This method uses {@link ThreadLocalRandom} for random numbers. It is sufficient for
* most cases, as only an "earliest" {@link Instant} is returned, but the actual
* renewal process also depends on cron job execution times and other factors like
* system load.
*
* The result is empty if it is impossible to renew the certificate in time, under the
* given circumstances. This is either because the time window already ended in the
* past, or because the cron job would not be executed before the ending of the time
* window. In this case, it is recommended to renew the certificate immediately.
*
* @param frequency
* Frequency of the cron job executing the certificate renewals. May be
* {@code null} if there is no cron job, and the renewal is going to be
* executed exactly at the given {@link Instant}.
* @return Random {@link Instant} when the certificate should be renewed. This instant
* might be slightly in the past. In this case, start the renewal process at the next
* possible regular moment.
*/
public Optional getRandomProposal(@Nullable TemporalAmount frequency) {
assertValidTimeWindow();
Instant start = Instant.now();
Instant suggestedStart = getSuggestedWindowStart();
if (start.isBefore(suggestedStart)) {
start = suggestedStart;
}
Instant end = getSuggestedWindowEnd();
if (frequency != null) {
end = end.minus(frequency);
}
if (!end.isAfter(start)) {
return Optional.empty();
}
return Optional.of(Instant.ofEpochMilli(ThreadLocalRandom.current().nextLong(
start.toEpochMilli(),
end.toEpochMilli())));
}
/**
* Asserts that the end of the suggested time window is after the start.
*/
private void assertValidTimeWindow() {
if (getSuggestedWindowStart().isAfter(getSuggestedWindowEnd())) {
throw new AcmeProtocolException("Received an invalid suggested window");
}
}
@Override
public Optional 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;
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/RevocationReason.java
================================================
/*
* 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;
import java.util.Arrays;
/**
* An enumeration of revocation reasons.
*
* @see RFC 5280 Section
* 5.3.1
*/
public enum RevocationReason {
UNSPECIFIED(0),
KEY_COMPROMISE(1),
CA_COMPROMISE(2),
AFFILIATION_CHANGED(3),
SUPERSEDED(4),
CESSATION_OF_OPERATION(5),
CERTIFICATE_HOLD(6),
REMOVE_FROM_CRL(8),
PRIVILEGE_WITHDRAWN(9),
AA_COMPROMISE(10);
private final int reasonCode;
RevocationReason(int reasonCode) {
this.reasonCode = reasonCode;
}
/**
* Returns the reason code as defined in RFC 5280.
*/
public int getReasonCode() {
return reasonCode;
}
/**
* Returns the {@link RevocationReason} that matches the reason code.
*
* @param reasonCode
* Reason code as defined in RFC 5280
* @return Matching {@link RevocationReason}
* @throws IllegalArgumentException if the reason code is unknown or invalid
*/
public static RevocationReason code(int reasonCode) {
return Arrays.stream(values())
.filter(rr -> rr.reasonCode == reasonCode)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown revocation reason code: " + reasonCode));
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Session.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util.Objects.requireNonNull;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.security.KeyPair;
import java.time.ZonedDateTime;
import java.util.EnumMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
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.NonceHolder;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.provider.GenericAcmeProvider;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value;
/**
* A {@link Session} tracks the entire communication with a CA.
*
* To create a session instance, use its constructor. It requires the URI of the ACME
* server to connect to. This can be the location of the CA's directory (via {@code http}
* or {@code https} protocol), or a special URI (via {@code acme} protocol). See the
* documentation about valid URIs.
*
* Starting with version 4.0.0, a session instance can be shared between multiple threads.
* A session won't perform parallel HTTP connections. For high-load scenarios, it is
* recommended to use multiple sessions.
*/
public class Session {
private static final GenericAcmeProvider GENERIC_PROVIDER = new GenericAcmeProvider();
private final AtomicReference> resourceMap = new AtomicReference<>();
private final AtomicReference metadata = new AtomicReference<>();
private final AtomicReference httpClient = new AtomicReference<>();
private final ReentrantLock nonceLock = new ReentrantLock();
private final NetworkSettings networkSettings = new NetworkSettings();
private final URI serverUri;
private final AcmeProvider provider;
private @Nullable String nonce;
private @Nullable Locale locale = Locale.getDefault();
private String languageHeader = AcmeUtils.localeToLanguageHeader(Locale.getDefault());
protected @Nullable ZonedDateTime directoryLastModified;
protected @Nullable ZonedDateTime directoryExpires;
/**
* Creates a new {@link Session}.
*
* @param serverUri
* URI string of the ACME server to connect to. This is either the location of
* the CA's ACME directory (using {@code http} or {@code https} protocol), or
* a special URI (using the {@code acme} protocol).
* @throws IllegalArgumentException
* if no ACME provider was found for the server URI.
*/
public Session(String serverUri) {
this(URI.create(serverUri));
}
/**
* Creates a new {@link Session}.
*
* @param serverUri
* {@link URI} of the ACME server to connect to. This is either the location
* of the CA's ACME directory (using {@code http} or {@code https} protocol),
* or a special URI (using the {@code acme} protocol).
* @throws IllegalArgumentException
* if no ACME provider was found for the server URI.
*/
public Session(URI serverUri) {
this.serverUri = requireNonNull(serverUri, "serverUri");
if (GENERIC_PROVIDER.accepts(serverUri)) {
provider = GENERIC_PROVIDER;
return;
}
var providers = ServiceLoader.load(AcmeProvider.class);
provider = StreamSupport.stream(providers.spliterator(), false)
.filter(p -> p.accepts(serverUri))
.reduce((a, b) -> {
throw new IllegalArgumentException("Both ACME providers "
+ a.getClass().getSimpleName() + " and "
+ b.getClass().getSimpleName() + " accept "
+ serverUri + ". Please check your classpath.");
})
.orElseThrow(() -> new IllegalArgumentException("No ACME provider found for " + serverUri));
}
/**
* Creates a new {@link Session} using the given {@link AcmeProvider}.
*
* This constructor is only to be used for testing purposes.
*
* @param serverUri
* {@link URI} of the ACME server
* @param provider
* {@link AcmeProvider} to be used
* @since 2.8
*/
public Session(URI serverUri, AcmeProvider provider) {
this.serverUri = requireNonNull(serverUri, "serverUri");
this.provider = requireNonNull(provider, "provider");
if (!provider.accepts(serverUri)) {
throw new IllegalArgumentException("Provider does not accept " + serverUri);
}
}
/**
* Logs into an existing account.
*
* @param accountLocation
* Location {@link URL} of the account
* @param accountKeyPair
* Account {@link KeyPair}
* @return {@link Login} to this account
*/
public Login login(URL accountLocation, KeyPair accountKeyPair) {
return new Login(accountLocation, accountKeyPair, this);
}
/**
* Gets the ACME server {@link URI} of this session.
*/
public URI getServerUri() {
return serverUri;
}
/**
* Locks the Session for the current thread, and returns a {@link NonceHolder}.
*
* The current thread can lock the nonce multiple times. Other threads have to wait
* until the current thread unlocks the nonce.
*
* @since 4.0.0
*/
public NonceHolder lockNonce() {
nonceLock.lock();
return new NonceHolder() {
@Override
public String getNonce() {
return Session.this.nonce;
}
@Override
public void setNonce(@Nullable String nonce) {
Session.this.nonce = nonce;
}
@Override
public void close() {
nonceLock.unlock();
}
};
}
/**
* Gets the current locale of this session, or {@code null} if no special language is
* selected.
*/
@Nullable
public Locale getLocale() {
return locale;
}
/**
* Sets the locale used in this session. The locale is passed to the server as
* Accept-Language header. The server may respond with localized messages.
* The default is the system's language. If set to {@code null}, any language will be
* accepted.
*/
public void setLocale(@Nullable Locale locale) {
this.locale = locale;
this.languageHeader = AcmeUtils.localeToLanguageHeader(locale);
}
/**
* Gets an Accept-Language header value that matches the current locale. This method
* is mainly for internal use.
*
* @since 3.0.0
*/
public String getLanguageHeader() {
return languageHeader;
}
/**
* Returns the current {@link NetworkSettings}.
*
* @return {@link NetworkSettings}
* @since 2.8
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public NetworkSettings networkSettings() {
return networkSettings;
}
/**
* Returns the {@link AcmeProvider} that is used for this session.
*
* @return {@link AcmeProvider}
*/
public AcmeProvider provider() {
return provider;
}
/**
* Returns a new {@link Connection} to the ACME server.
*
* @return {@link Connection}
*/
public Connection connect() {
return provider.connect(getServerUri(), networkSettings, getHttpClient());
}
/**
* Returns the shared {@link HttpClient} instance for this session. The instance is
* created lazily on first access and then cached for reuse. This allows multiple
* connections to share the same HTTP client, improving resource utilization and
* connection pooling.
*
* @return Shared {@link HttpClient} instance
* @since 4.0.0
*/
public HttpClient getHttpClient() {
var result = httpClient.get();
if (result == null) {
result = httpClient.updateAndGet(
client -> client != null
? client
: provider.createHttpClient(networkSettings)
);
}
return result;
}
/**
* Gets the {@link URL} of the given {@link Resource}. This may involve connecting to
* the server and fetching the directory. The result is cached.
*
* @param resource
* {@link Resource} to get the {@link URL} of
* @return {@link URL} of the resource
* @throws AcmeException
* if the server does not offer the {@link Resource}
*/
public URL resourceUrl(Resource resource) throws AcmeException {
return resourceUrlOptional(resource)
.orElseThrow(() -> new AcmeNotSupportedException(resource.path()));
}
/**
* Gets the {@link URL} of the given {@link Resource}. This may involve connecting to
* the server and fetching the directory. The result is cached.
*
* @param resource
* {@link Resource} to get the {@link URL} of
* @return {@link URL} of the resource, or empty if the resource is not available.
* @since 3.0.0
*/
public Optional resourceUrlOptional(Resource resource) throws AcmeException {
readDirectory();
return Optional.ofNullable(resourceMap.get()
.get(requireNonNull(resource, "resource")));
}
/**
* Gets the metadata of the provider's directory. This may involve connecting to the
* server and fetching the directory. The result is cached.
*
* @return {@link Metadata}. May contain no data, but is never {@code null}.
*/
public Metadata getMetadata() throws AcmeException {
readDirectory();
return metadata.get();
}
/**
* Returns the date when the directory has been modified the last time.
*
* @return The last modification date of the directory, or {@code null} if unknown
* (directory has not been read yet or did not provide this information).
* @since 2.10
*/
@Nullable
public ZonedDateTime getDirectoryLastModified() {
return directoryLastModified;
}
/**
* Sets the date when the directory has been modified the last time. Should only be
* invoked by {@link AcmeProvider} implementations.
*
* @param directoryLastModified
* The last modification date of the directory, or {@code null} if unknown
* (directory has not been read yet or did not provide this information).
* @since 2.10
*/
public void setDirectoryLastModified(@Nullable ZonedDateTime directoryLastModified) {
this.directoryLastModified = directoryLastModified;
}
/**
* Returns the date when the current directory records will expire. A fresh copy of
* the directory will be fetched automatically after that instant.
*
* @return The expiration date, or {@code null} if the server did not provide this
* information.
* @since 2.10
*/
@Nullable
public ZonedDateTime getDirectoryExpires() {
return directoryExpires;
}
/**
* Sets the date when the current directory will expire. Should only be invoked by
* {@link AcmeProvider} implementations.
*
* @param directoryExpires
* Expiration date, or {@code null} if the server did not provide this
* information.
* @since 2.10
*/
public void setDirectoryExpires(@Nullable ZonedDateTime directoryExpires) {
this.directoryExpires = directoryExpires;
}
/**
* Returns {@code true} if a copy of the directory is present in a local cache. It is
* not evaluated if the cached copy has expired though.
*
* @return {@code true} if a directory is available.
* @since 2.10
*/
public boolean hasDirectory() {
return resourceMap.get() != null;
}
/**
* Purges the directory cache. Makes sure that a fresh copy of the directory will be
* read from the CA on the next time the directory is accessed.
*
* @since 3.0.0
*/
public void purgeDirectoryCache() {
setDirectoryLastModified(null);
setDirectoryExpires(null);
resourceMap.set(null);
}
/**
* Reads the provider's directory, then rebuild the resource map. The resource map
* is unchanged if the {@link AcmeProvider} returns that the directory has not been
* changed on the remote side.
*/
private void readDirectory() throws AcmeException {
var directoryJson = provider().directory(this, getServerUri());
if (directoryJson == null) {
if (!hasDirectory()) {
throw new AcmeException("AcmeProvider did not provide a directory");
}
return;
}
var meta = directoryJson.get("meta");
if (meta.isPresent()) {
metadata.set(new Metadata(meta.asObject()));
} else {
metadata.set(new Metadata(JSON.empty()));
}
var map = new EnumMap(Resource.class);
for (var res : Resource.values()) {
directoryJson.get(res.path())
.map(Value::asURL)
.ifPresent(url -> map.put(res, url));
}
resourceMap.set(map);
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/Status.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import java.util.Arrays;
import java.util.Locale;
/**
* An enumeration of status codes of challenges and authorizations.
*/
public enum Status {
/**
* The server has created the resource, and is waiting for the client to process it.
*/
PENDING,
/**
* The {@link Order} is ready to be finalized. Invoke {@link Order#execute(byte[])}.
*/
READY,
/**
* The server is processing the resource. The client should invoke
* {@link AcmeJsonResource#fetch()} and re-check the status.
*/
PROCESSING,
/**
* The resource is valid and can be used as intended.
*/
VALID,
/**
* An error or authorization/validation failure has occured. The client should check
* for error messages.
*/
INVALID,
/**
* The {@link Authorization} has been revoked by the server.
*/
REVOKED,
/**
* The {@link Account} or {@link Authorization} has been deactivated by the client.
*/
DEACTIVATED,
/**
* The {@link Authorization} is expired.
*/
EXPIRED,
/**
* An auto-renewing {@link Order} is canceled.
*
* @since 2.3
*/
CANCELED,
/**
* The server did not provide a status, or the provided status is not a specified ACME
* status.
*/
UNKNOWN;
/**
* Parses the string and returns a corresponding Status object.
*
* @param str
* String to parse
* @return {@link Status} matching the string, or {@link Status#UNKNOWN} if there was
* no match
*/
public static Status parse(String str) {
var check = str.toUpperCase(Locale.ENGLISH);
return Arrays.stream(values())
.filter(s -> s.name().equals(check))
.findFirst()
.orElse(Status.UNKNOWN);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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 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;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSON.Value;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A generic challenge. It can be used as a base class for actual challenge
* implementations, but it is also used if the ACME server offers a proprietary challenge
* that is unknown to acme4j.
*
* Subclasses must override {@link Challenge#acceptable(String)} so it only accepts its
* own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all
* required data to the challenge response.
*/
public class Challenge extends AcmeJsonResource implements PollableResource {
@Serial
private static final long serialVersionUID = 2338794776848388099L;
private static final Logger LOG = LoggerFactory.getLogger(Challenge.class);
protected static final String KEY_TYPE = "type";
protected static final String KEY_URL = "url";
protected static final String KEY_STATUS = "status";
protected static final String KEY_VALIDATED = "validated";
protected static final String KEY_ERROR = "error";
/**
* Creates a new generic {@link Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public Challenge(Login login, JSON data) {
super(login, data.get(KEY_URL).asURL());
setJSON(data);
}
/**
* Returns the challenge type by name (e.g. "http-01").
*/
public String getType() {
return getJSON().get(KEY_TYPE).asString();
}
/**
* Returns the current status of the challenge.
*
* Possible values are: {@link Status#PENDING}, {@link Status#PROCESSING},
* {@link Status#VALID}, {@link Status#INVALID}.
*
* 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();
}
/**
* Returns the validation date, if returned by the server.
*/
public Optional getValidated() {
return getJSON().get(KEY_VALIDATED).map(Value::asInstant);
}
/**
* Returns a reason why the challenge has failed in the past, if returned by the
* server. If there are multiple errors, they can be found in
* {@link Problem#getSubProblems()}.
*/
public Optional getError() {
return getJSON().get(KEY_ERROR).map(it -> it.asProblem(getLocation()));
}
/**
* Prepares the response message for triggering the challenge. Subclasses can add
* fields to the {@link JSONBuilder} as required by the challenge. Implementations of
* subclasses should make sure that {@link #prepareResponse(JSONBuilder)} of the
* superclass is invoked.
*
* @param response
* {@link JSONBuilder} to write the response to
*/
protected void prepareResponse(JSONBuilder response) {
// Do nothing here...
}
/**
* Checks if the type is acceptable to this challenge. This generic class only checks
* if the type is not blank. Subclasses should instead check if the given type matches
* expected challenge type.
*
* @param type
* Type to check
* @return {@code true} if acceptable, {@code false} if not
*/
protected boolean acceptable(String type) {
return type != null && !type.trim().isEmpty();
}
@Override
protected void setJSON(JSON json) {
var type = json.get(KEY_TYPE).asString();
if (!acceptable(type)) {
throw new AcmeProtocolException("incompatible type " + type + " for this challenge");
}
var loc = json.get(KEY_URL).asString();
if (!loc.equals(getLocation().toString())) {
throw new AcmeProtocolException("challenge has changed its location");
}
super.setJSON(json);
}
/**
* Triggers this {@link Challenge}. The ACME server is requested to validate the
* response. Note that the validation is performed asynchronously by the ACME server.
*
* After a challenge is triggered, it changes to {@link Status#PENDING}. As soon as
* validation takes place, it changes to {@link Status#PROCESSING}. After validation
* the status changes to {@link Status#VALID} or {@link Status#INVALID}, depending on
* the outcome of the validation.
*
* If the challenge requires a resource to be set on your side (e.g. a DNS record or
* an HTTP file), it must be reachable from public before {@link #trigger()}
* is invoked, and must not be taken down until the challenge has reached
* {@link Status#VALID} or {@link Status#INVALID}.
*
* 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");
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
prepareResponse(claims);
conn.sendSignedRequest(getLocation(), claims, getLogin());
setJSON(conn.readJsonResponse());
}
}
/**
* Waits until the challenge is completed.
*
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
*
* 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);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Dns01Challenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.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;
/**
* Implements the {@value TYPE} challenge. It requires a specific DNS record for domain
* validation. See the acme4j documentation for a detailed explanation.
*/
public class Dns01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = 6964687027713533075L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "dns-01";
/**
* The prefix of the domain name to be used for the DNS TXT record.
*/
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.
*
* @param identifier
* 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 4.0.0
*/
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 _acme-challenge.www.example.org.}, note
* the trailing full stop character).
* @since 4.0.0
*/
public String getRRName(String domain) {
return RECORD_NAME_PREFIX + '.' + domain + '.';
}
/**
* Returns the digest string to be set in the domain's {@code _acme-challenge} TXT
* record.
*/
public String getDigest() {
return base64UrlEncode(sha256hash(getAuthorization()));
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsAccount01Challenge.java
================================================
/*
* 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);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsPersist01Challenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2026 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 java.util.Objects.requireNonNull;
import java.io.Serial;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
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 5.0.0
*/
public class DnsPersist01Challenge extends Challenge {
@Serial
private static final long serialVersionUID = 7532514098897449519L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "dns-persist-01";
protected static final String KEY_ISSUER_DOMAIN_NAMES = "issuer-domain-names";
protected static final String RECORD_NAME_PREFIX = "_validation-persist";
protected static final String KEY_ACCOUNT_URI = "accounturi";
private static final int ISSUER_SIZE_LIMIT = 10; // according to the specs
private static final int DOMAIN_LENGTH_LIMIT = 253; // according to the specs
private @Nullable List issuerDomainNames;
/**
* Creates a new generic {@link DnsPersist01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public DnsPersist01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Returns the list of issuer-domain-names from the CA. The list is guaranteed to
* have at least one element.
*/
public List getIssuerDomainNames() {
if (issuerDomainNames == null) {
var domainNames = getJSON().get(KEY_ISSUER_DOMAIN_NAMES).asArray().stream()
.map(JSON.Value::asString)
.map(AcmeUtils::toAce)
.toList();
if (domainNames.isEmpty()) {
// malform check is mandatory according to the specification
throw new AcmeProtocolException("issuer-domain-names missing or empty");
}
if (domainNames.size() > ISSUER_SIZE_LIMIT) {
// malform check is mandatory according to the specification
throw new AcmeProtocolException("issuer-domain-names size limit exceeded: "
+ domainNames.size() + " > " + ISSUER_SIZE_LIMIT);
}
if (domainNames.stream().anyMatch(it -> it.endsWith("."))) {
throw new AcmeProtocolException("issuer-domain-names must not have trailing dots");
}
if (!domainNames.stream().allMatch(it -> it.length() <= DOMAIN_LENGTH_LIMIT)) {
throw new AcmeProtocolException("issuer-domain-names content too long");
}
issuerDomainNames = domainNames;
}
return Collections.unmodifiableList(issuerDomainNames);
}
/**
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
* record.
*
* @param identifier
* Domain {@link Identifier} of the domain to be validated
* @return Resource Record name (e.g. {@code _validation-persist.www.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 _validation-persist.www.example.org.},
* note the trailing full stop character).
*/
public String getRRName(String domain) {
return RECORD_NAME_PREFIX + '.' + AcmeUtils.toAce(domain) + '.';
}
/**
* Returns a builder for the RDATA value of the DNS TXT record.
*
* @return Builder for the RDATA
*/
public Builder buildRData() {
return new Builder(getLogin(), getIssuerDomainNames());
}
/**
* Convenience call to get a standard RDATA without optional tags.
*
* @return RRDATA
*/
public String getRData() {
return buildRData().build();
}
/**
* Returns the Account URI that is expected to request the validation.
*
* @since 5.1.0
*/
public URL getAccountUrl() {
return getJSON().get(KEY_ACCOUNT_URI).asURL();
}
@Override
protected void invalidate() {
super.invalidate();
issuerDomainNames = null;
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
@Override
protected void setJSON(JSON json) {
super.setJSON(json);
// TODO: In a future release, KEY_ACCOUNT_URI is expected to be mandatory,
// and this check will always apply!
if (getJSON().contains(KEY_ACCOUNT_URI)) {
try {
var expectedAccount = getJSON().get(KEY_ACCOUNT_URI).asURI();
var actualAccount = getLogin().getAccount().getLocation().toURI();
if (!actualAccount.equals(expectedAccount)) {
throw new AcmeProtocolException("challenge is intended for a different account: " + expectedAccount);
}
} catch (URISyntaxException ex) {
throw new IllegalStateException("Account URL is not an URI?", ex);
}
}
}
/**
* Builder for RDATA.
*
* The following default values are assumed unless overridden by one of the builder
* methods:
*
* The first issuer domain name from the list of issuer domain names is used
* No wildcard domain
* No persistence limit
* Generate quote-enclosed strings
*
*/
public static class Builder {
private final Login login;
private final List issuerDomainNames;
private String issuer;
private boolean wildcard = false;
private boolean quotes = true;
private @Nullable Instant persistUntil = null;
private Builder(Login login, List issuerDomainNames) {
this.login = login;
this.issuerDomainNames = issuerDomainNames;
this.issuer = issuerDomainNames.get(0);
}
/**
* Change the issuer domain name.
*
* @param issuer
* Issuer domain name, must be one of
* {@link DnsPersist01Challenge#getIssuerDomainNames()}.
*/
public Builder issuerDomainName(String issuer) {
requireNonNull(issuer, "issuer");
if (!issuerDomainNames.contains(issuer)) {
throw new IllegalArgumentException("Domain " + issuer + " is not in the list of issuer-domain-names");
}
this.issuer = issuer;
return this;
}
/**
* Request wildcard validation.
*/
public Builder wildcard() {
wildcard = true;
return this;
}
/**
* Instant until this RDATA is valid. The CA must not use this record after that.
*
* @param instant
* Persist until instant
*/
public Builder persistUntil(Instant instant) {
persistUntil = requireNonNull(instant, "instant");
return this;
}
/**
* Do not use quote-enclosed strings. Proper formatting of the resulting RDATA
* must be done externally!
*/
public Builder noQuotes() {
quotes = false;
return this;
}
/**
* Build the RDATA string for the DNS TXT record.
*/
public String build() {
var parts = new ArrayList();
parts.add(issuer);
parts.add("accounturi=" + login.getAccount().getLocation());
if (wildcard) {
parts.add("policy=wildcard");
}
if (persistUntil != null) {
parts.add("persistUntil=" + persistUntil.getEpochSecond());
}
if (quotes) {
// Quotes inside the parts should be escaped. However, we don't expect
// that any part contains qoutes.
return '"' + String.join(";\" \" ", parts) + '"';
} else {
return String.join("; ", parts);
}
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Http01Challenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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 java.io.Serial;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Implements the {@value TYPE} challenge. For domain validation, it requires a specific
* file that can be retrieved from the domain via HTTP. See the acme4j documentation for a
* detailed explanation.
*/
public class Http01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = 3322211185872544605L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "http-01";
/**
* Creates a new generic {@link Http01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public Http01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Returns the token to be used for this challenge.
*/
@Override
public String getToken() {
return super.getToken();
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsAlpn01Challenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.sha256hash;
import java.io.IOException;
import java.io.Serial;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.util.CertificateUtils;
/**
* Implements the {@value TYPE} challenge. It requires a specific certificate that can be
* retrieved from the domain via HTTPS request. See the acme4j documentation for a
* detailed explanation.
*
* @since 2.1
*/
public class TlsAlpn01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = -5590351078176091228L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "tls-alpn-01";
/**
* OID of the {@code acmeValidation} extension.
*/
public static final String ACME_VALIDATION_OID = "1.3.6.1.5.5.7.1.31";
/**
* {@code acme-tls/1} protocol.
*/
public static final String ACME_TLS_1_PROTOCOL = "acme-tls/1";
/**
* Creates a new generic {@link TlsAlpn01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public TlsAlpn01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Returns the value that is to be used as {@code acmeValidation} extension in
* the test certificate.
*/
public byte[] getAcmeValidation() {
return sha256hash(getAuthorization());
}
/**
* Creates a self-signed {@link X509Certificate} for this challenge. The certificate
* is valid for 7 days.
*
* @param keypair
* A domain {@link KeyPair} to be used for the challenge
* @param id
* The {@link Identifier} that is to be validated
* @return Created certificate
* @since 3.0.0
*/
public X509Certificate createCertificate(KeyPair keypair, Identifier id) {
try {
return CertificateUtils.createTlsAlpn01Certificate(keypair, id, getAcmeValidation());
} catch (IOException ex) {
throw new IllegalArgumentException("Bad certificate parameters", ex);
}
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TokenChallenge.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.base64UrlEncode;
import java.io.Serial;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JoseUtils;
/**
* A generic extension of {@link Challenge} that handles challenges with a {@code token}
* and {@code keyAuthorization}.
*/
public class TokenChallenge extends Challenge {
@Serial
private static final long serialVersionUID = 1634133407432681800L;
protected static final String KEY_TOKEN = "token";
/**
* Creates a new generic {@link TokenChallenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public TokenChallenge(Login login, JSON data) {
super(login, data);
}
/**
* Gets the token.
*/
protected String getToken() {
var token = getJSON().get(KEY_TOKEN).asString();
if (!AcmeUtils.isValidBase64Url(token)) {
throw new AcmeProtocolException("Invalid token: " + token);
}
return token;
}
/**
* Computes the key authorization for the given token.
*
* The default is {@code token + '.' + base64url(jwkThumbprint)}. Subclasses may
* override this method if a different algorithm is used.
*
* @param token
* Token to be used
* @return Key Authorization string for that token
* @since 2.12
*/
protected String keyAuthorizationFor(String token) {
var pk = getLogin().getPublicKey();
return token + '.' + base64UrlEncode(JoseUtils.thumbprint(pk));
}
/**
* Returns the authorization string.
*
* The default uses {@link #keyAuthorizationFor(String)} to compute the key
* authorization of {@link #getToken()}. Subclasses may override this method if a
* different algorithm is used.
*/
public String getAuthorization() {
return keyAuthorizationFor(getToken());
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/challenge/package-info.java
================================================
/*
* 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 all standard challenges, as well as base classes for challenges
* that are proprietary to a CA.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.challenge;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import java.net.URL;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
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.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Connects to the ACME server and offers different methods for invoking the API.
*
* The actual way of communicating with the ACME server is intentionally left open.
* Implementations could use other means than HTTP, or could mock the communication for
* unit testing.
*/
public interface Connection extends AutoCloseable {
/**
* Resets the session nonce, by fetching a new one.
*
* @param session
* {@link Session} instance to fetch a nonce for
*/
void resetNonce(Session session) throws AcmeException;
/**
* Sends a simple GET request.
*
* If the response code was not HTTP status 200, an {@link AcmeException} matching
* the error is raised.
*
* @param url
* {@link URL} to send the request to.
* @param session
* {@link Session} instance to be used for tracking
* @param ifModifiedSince
* {@link ZonedDateTime} to be sent as "If-Modified-Since" header, or
* {@code null} if this header is not to be used
* @return HTTP status that was returned
*/
int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
throws AcmeException;
/**
* Sends a signed POST-as-GET request for a certificate resource. Requires a
* {@link Login} for the session and {@link KeyPair}. The {@link Login} account
* location is sent in a "kid" protected header.
*
* If the server does not return a 200 class status code, an {@link AcmeException} is
* raised matching the error.
*
* @param url
* {@link URL} to send the request to.
* @param login
* {@link Login} instance to be used for signing and tracking.
* @return HTTP 200 class status that was returned
*/
int sendCertificateRequest(URL url, Login login) throws AcmeException;
/**
* Sends a signed POST-as-GET request. Requires a {@link Login} for the session and
* {@link KeyPair}. The {@link Login} account location is sent in a "kid" protected
* header.
*
* If the server does not return a 200 class status code, an {@link AcmeException} is
* raised matching the error.
*
* @param url
* {@link URL} to send the request to.
* @param login
* {@link Login} instance to be used for signing and tracking.
* @return HTTP 200 class status that was returned
*/
int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException;
/**
* Sends a signed POST request. Requires a {@link Login} for the session and
* {@link KeyPair}. The {@link Login} account location is sent in a "kid" protected
* header.
*
* If the server does not return a 200 class status code, an {@link AcmeException} is
* raised matching the error.
*
* @param url
* {@link URL} to send the request to.
* @param claims
* {@link JSONBuilder} containing claims.
* @param login
* {@link Login} instance to be used for signing and tracking.
* @return HTTP 200 class status that was returned
*/
int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException;
/**
* Sends a signed POST request. Only requires a {@link Session}.
*
* If the server does not return a 200 class status code, an {@link AcmeException} is
* raised matching the error.
*
* @param url
* {@link URL} to send the request to.
* @param claims
* {@link JSONBuilder} containing claims.
* @param session
* {@link Session} instance to be used for tracking.
* @param signer
* {@link RequestSigner} to sign the request with
* @return HTTP 200 class status that was returned
* @since 5.0.0
*/
int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer)
throws AcmeException;
/**
* Reads a server response as JSON object.
*
* @return The JSON response.
*/
JSON readJsonResponse() throws AcmeException;
/**
* Reads a certificate and its chain of issuers.
*
* @return List of X.509 certificate and chain that was read.
*/
List readCertificates() throws AcmeException;
/**
* Returns the Retry-After header if present.
*
* @since 3.0.0
*/
Optional getRetryAfter();
/**
* Gets the nonce from the nonce header.
*
* @return Base64 encoded nonce, or empty if no nonce header was set
*/
Optional getNonce();
/**
* Gets a location from the {@code Location} header.
*
* Relative links are resolved against the last request's URL.
*
* @return Location {@link URL}
* @throws org.shredzone.acme4j.exception.AcmeProtocolException if the location
* header is missing
*/
URL getLocation();
/**
* Returns the content of the last-modified header, if present.
*
* @return Date in the Last-Modified header, or empty if the server did not provide
* this information.
* @since 2.10
*/
Optional getLastModified();
/**
* Returns the expiration date of the resource, if present.
*
* @return Expiration date, either from the Cache-Control or Expires header. If empty,
* the server did not provide an expiration date, or forbid caching.
* @since 2.10
*/
Optional getExpiration();
/**
* Gets one or more relation links from the header. The result is expected to be a
* URL.
*
* Relative links are resolved against the last request's URL.
*
* @param relation
* Link relation
* @return Collection of links. Empty if there was no such relation.
*/
Collection getLinks(String relation);
/**
* Closes the {@link Connection}, releasing all resources.
*/
@Override
void close();
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.connector;
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
import static java.util.function.Predicate.not;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
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.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Default implementation of {@link Connection}. It communicates with the ACME server via
* HTTP, with a client that is provided by the given {@link HttpConnector}.
*/
public class DefaultConnection implements Connection {
private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);
private static final int HTTP_OK = 200;
private static final int HTTP_CREATED = 201;
private static final int HTTP_NO_CONTENT = 204;
private static final int HTTP_NOT_MODIFIED = 304;
private static final String ACCEPT_HEADER = "Accept";
private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
private static final String CACHE_CONTROL_HEADER = "Cache-Control";
private static final String CONTENT_TYPE_HEADER = "Content-Type";
private static final String DATE_HEADER = "Date";
private static final String EXPIRES_HEADER = "Expires";
private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
private static final String LAST_MODIFIED_HEADER = "Last-Modified";
private static final String LINK_HEADER = "Link";
private static final String LOCATION_HEADER = "Location";
private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
private static final String RETRY_AFTER_HEADER = "Retry-After";
private static final String DEFAULT_CHARSET = "utf-8";
private static final String MIME_JSON = "application/json";
private static final String MIME_JSON_PROBLEM = "application/problem+json";
private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain";
private static final URI BAD_NONCE_ERROR = URI.create("urn:ietf:params:acme:error:badNonce");
private static final int MAX_ATTEMPTS = 10;
private static final Pattern NO_CACHE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*no-(?:cache|store)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
private static final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*max-age=(\\d+)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("^\\d+$");
protected final HttpConnector httpConnector;
protected final HttpClient httpClient;
protected @Nullable HttpResponse lastResponse;
/**
* Creates a new {@link DefaultConnection}.
*
* @param httpConnector
* {@link HttpConnector} to be used for HTTP connections
*/
public DefaultConnection(HttpConnector httpConnector) {
this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
this.httpClient = httpConnector.getHttpClient();
}
@Override
public void resetNonce(Session session) throws AcmeException {
assertConnectionIsClosed();
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(null);
var newNonceUrl = session.resourceUrl(Resource.NEW_NONCE);
LOG.debug("HEAD {}", newNonceUrl);
sendRequest(session, newNonceUrl, b ->
b.method("HEAD", HttpRequest.BodyPublishers.noBody()));
logHeaders();
var rc = getResponse().statusCode();
if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
throw new AcmeException("Server responded with HTTP " + rc + " while trying to retrieve a nonce");
}
nonceHolder.setNonce(getNonce()
.orElseThrow(() -> new AcmeProtocolException("Server did not provide a nonce"))
);
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
} finally {
close();
}
}
@Override
public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
throws AcmeException {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(session, "session");
assertConnectionIsClosed();
LOG.debug("GET {}", url);
try (var nonceHolder = session.lockNonce()) {
sendRequest(session, url, builder -> {
builder.GET();
builder.header(ACCEPT_HEADER, MIME_JSON);
if (ifModifiedSince != null) {
builder.header(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
}
});
logHeaders();
getNonce().ifPresent(nonceHolder::setNonce);
var rc = getResponse().statusCode();
if (rc != HTTP_OK && rc != HTTP_CREATED && (rc != HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
throwAcmeException();
}
return rc;
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}
}
@Override
public int sendCertificateRequest(URL url, Login login) throws AcmeException {
return sendSignedRequest(url, null, login.getSession(), MIME_CERTIFICATE_CHAIN,
login::createJoseRequest);
}
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException {
return sendSignedRequest(url, null, login.getSession(), MIME_JSON,
login::createJoseRequest);
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
return sendSignedRequest(url, claims, login.getSession(), MIME_JSON,
login::createJoseRequest);
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer)
throws AcmeException {
return sendSignedRequest(url, claims, session, MIME_JSON, signer);
}
@Override
public JSON readJsonResponse() throws AcmeException {
expectContentType(Set.of(MIME_JSON, MIME_JSON_PROBLEM));
try (var in = getResponseBody()) {
var result = JSON.parse(in);
LOG.debug("Result JSON: {}", result);
return result;
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}
}
@Override
public List readCertificates() throws AcmeException {
expectContentType(Set.of(MIME_CERTIFICATE_CHAIN));
try (var in = new TrimmingInputStream(getResponseBody())) {
var cf = CertificateFactory.getInstance("X.509");
return cf.generateCertificates(in).stream()
.map(X509Certificate.class::cast)
.toList();
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
} catch (CertificateException ex) {
throw new AcmeProtocolException("Failed to read certificate", ex);
}
}
@Override
public Optional getNonce() {
var nonceHeaderOpt = getResponse().headers()
.firstValue(REPLAY_NONCE_HEADER)
.map(String::trim)
.filter(not(String::isEmpty));
if (nonceHeaderOpt.isPresent()) {
var nonceHeader = nonceHeaderOpt.get();
if (!AcmeUtils.isValidBase64Url(nonceHeader)) {
throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
}
LOG.debug("Replay Nonce: {}", nonceHeader);
}
return nonceHeaderOpt;
}
@Override
public URL getLocation() {
return getResponse().headers()
.firstValue(LOCATION_HEADER)
.map(l -> {
LOG.debug("Location: {}", l);
return l;
})
.map(this::resolveRelative)
.orElseThrow(() -> new AcmeProtocolException("location header is missing"));
}
@Override
public Optional getLastModified() {
return getResponse().headers()
.firstValue(LAST_MODIFIED_HEADER)
.map(lm -> {
try {
return ZonedDateTime.parse(lm, RFC_1123_DATE_TIME);
} catch (DateTimeParseException ex) {
LOG.debug("Ignored invalid Last-Modified date: {}", lm, ex);
return null;
}
});
}
@Override
public Optional getExpiration() {
var cacheControlHeader = getResponse().headers()
.firstValue(CACHE_CONTROL_HEADER)
.filter(not(h -> NO_CACHE_PATTERN.matcher(h).matches()))
.map(MAX_AGE_PATTERN::matcher)
.filter(Matcher::matches)
.map(m -> Integer.parseInt(m.group(1)))
.filter(maxAge -> maxAge != 0)
.map(maxAge -> ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));
if (cacheControlHeader.isPresent()) {
return cacheControlHeader;
}
return getResponse().headers()
.firstValue(EXPIRES_HEADER)
.flatMap(header -> {
try {
return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
} catch (DateTimeParseException ex) {
LOG.debug("Ignored invalid Expires date: {}", header, ex);
return Optional.empty();
}
});
}
@Override
public Collection getLinks(String relation) {
return collectLinks(relation).stream()
.map(this::resolveRelative)
.toList();
}
@Override
public void close() {
lastResponse = null;
}
/**
* Sends a HTTP request via http client. This is the central method to be used for
* sending. It will create a {@link HttpRequest} by using the request builder,
* configure commnon headers, and then send the request via {@link HttpClient}.
*
* @param session
* {@link Session} to be used for sending
* @param url
* Target {@link URL}
* @param body
* Callback that completes the {@link HttpRequest.Builder} with the request
* body (e.g. HTTP method, request body, more headers).
*/
protected void sendRequest(Session session, URL url, Consumer body) throws IOException {
try {
var builder = httpConnector.createRequestBuilder(url)
.header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET)
.header(ACCEPT_LANGUAGE_HEADER, session.getLanguageHeader());
if (session.networkSettings().isCompressionEnabled()) {
builder.header(ACCEPT_ENCODING_HEADER, "gzip");
}
body.accept(builder);
lastResponse = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
} catch (InterruptedException ex) {
throw new IOException("Request was interrupted", ex);
}
}
/**
* Sends a signed POST request.
*
* @param url
* {@link URL} to send the request to.
* @param claims
* {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
* request.
* @param accept
* Accept header
* @return HTTP 200 class status that was returned
*/
protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims,
Session session, String accept, RequestSigner signer)
throws AcmeException {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(session, "session");
Objects.requireNonNull(accept, "accept");
Objects.requireNonNull(signer, "signer");
assertConnectionIsClosed();
var attempt = 1;
while (true) {
try {
return performRequest(url, claims, session, accept, signer);
} catch (AcmeServerException ex) {
if (!BAD_NONCE_ERROR.equals(ex.getType())) {
throw ex;
}
if (attempt == MAX_ATTEMPTS) {
throw ex;
}
LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS);
attempt++;
}
}
}
/**
* Performs the POST request.
*
* @param url
* {@link URL} to send the request to.
* @param claims
* {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
* request.
* @param accept
* Accept header
* @return HTTP 200 class status that was returned
*/
private int performRequest(URL url, @Nullable JSONBuilder claims, Session session,
String accept, RequestSigner signer) throws AcmeException {
try (var nonceHolder = session.lockNonce()) {
if (nonceHolder.getNonce() == null) {
resetNonce(session);
}
var jose = signer.createRequest(url, claims, nonceHolder.getNonce());
var outputData = jose.toString();
sendRequest(session, url, builder -> {
builder.POST(HttpRequest.BodyPublishers.ofString(outputData));
builder.header(ACCEPT_HEADER, accept);
builder.header(CONTENT_TYPE_HEADER, "application/jose+json");
});
logHeaders();
nonceHolder.setNonce(getNonce().orElse(null));
var rc = getResponse().statusCode();
if (rc != HTTP_OK && rc != HTTP_CREATED) {
throwAcmeException();
}
return rc;
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}
}
@Override
public Optional getRetryAfter() {
return getResponse().headers()
.firstValue(RETRY_AFTER_HEADER)
.map(this::parseRetryAfterHeader);
}
/**
* Parses the content of a Retry-After header. The header can either contain a
* relative or an absolute time.
*
* @param header
* Retry-After header
* @return Instant given in the header
* @throws AcmeProtocolException
* if the header content is invalid
*/
private Instant parseRetryAfterHeader(String header) {
// See RFC 2616 section 14.37
try {
// delta-seconds
if (DIGITS_ONLY_PATTERN.matcher(header).matches()) {
var delta = Integer.parseInt(header);
var date = getResponse().headers().firstValue(DATE_HEADER)
.map(d -> ZonedDateTime.parse(d, RFC_1123_DATE_TIME).toInstant())
.orElseGet(Instant::now);
return date.plusSeconds(delta);
}
// HTTP-date
return ZonedDateTime.parse(header, RFC_1123_DATE_TIME).toInstant();
} catch (RuntimeException ex) {
throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
}
}
/**
* Provides an {@link InputStream} of the response body. If the stream is compressed,
* it will also take care for decompression.
*/
private InputStream getResponseBody() throws IOException {
var stream = getResponse().body();
if (stream == null) {
throw new AcmeProtocolException("Unexpected empty response");
}
if (getResponse().headers().firstValue("Content-Encoding")
.filter("gzip"::equalsIgnoreCase)
.isPresent()) {
stream = new GZIPInputStream(stream);
}
return stream;
}
/**
* Throws an {@link AcmeException}. This method throws an exception that tries to
* explain the error as precisely as possible.
*/
private void throwAcmeException() throws AcmeException {
try {
if (getResponse().headers().firstValue(CONTENT_TYPE_HEADER)
.map(AcmeUtils::getContentType)
.filter(MIME_JSON_PROBLEM::equals)
.isEmpty()) {
// Generic HTTP error
throw new AcmeException("HTTP " + getResponse().statusCode());
}
var problem = new Problem(readJsonResponse(), getResponse().request().uri().toURL());
var error = AcmeUtils.stripErrorPrefix(problem.getType().toString());
if ("unauthorized".equals(error)) {
throw new AcmeUnauthorizedException(problem);
}
if ("userActionRequired".equals(error)) {
var tos = collectLinks("terms-of-service").stream()
.findFirst()
.map(this::resolveUri)
.orElse(null);
throw new AcmeUserActionRequiredException(problem, tos);
}
if ("rateLimited".equals(error)) {
var retryAfter = getRetryAfter();
var rateLimits = getLinks("help");
throw new AcmeRateLimitedException(problem, retryAfter.orElse(null), rateLimits);
}
throw new AcmeServerException(problem);
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}
}
/**
* Checks if the returned content type is in the list of expected types.
*
* @param expectedTypes
* content types that are accepted
* @throws AcmeProtocolException
* if the returned content type is different
*/
private void expectContentType(Set expectedTypes) {
var contentType = getResponse().headers()
.firstValue(CONTENT_TYPE_HEADER)
.map(AcmeUtils::getContentType)
.orElseThrow(() -> new AcmeProtocolException("No content type header found"));
if (!expectedTypes.contains(contentType)) {
throw new AcmeProtocolException("Unexpected content type: " + contentType);
}
}
/**
* Returns the response of the last request. If there is no connection currently
* open, an exception is thrown instead.
*
* Note that the response provides an {@link InputStream} that can be read only
* once.
*/
private HttpResponse getResponse() {
if (lastResponse == null) {
throw new IllegalStateException("Not connected.");
}
return lastResponse;
}
/**
* Asserts that the connection is currently closed. Throws an exception if not.
*/
private void assertConnectionIsClosed() {
if (lastResponse != null) {
throw new IllegalStateException("Previous connection is not closed.");
}
}
/**
* Log all HTTP headers in debug mode.
*/
private void logHeaders() {
if (!LOG.isDebugEnabled()) {
return;
}
getResponse().headers().map().forEach((key, headers) ->
headers.forEach(value ->
LOG.debug("HEADER {}: {}", key, value)
)
);
}
/**
* Collects links of the given relation.
*
* @param relation
* Link relation
* @return Collection of links, unconverted
*/
private Collection collectLinks(String relation) {
var p = Pattern.compile("<([^>]+)>\\s*;[^<]*?\\brel=\"?" + Pattern.quote(relation) + "\"?(?:[\\s,;]|$)");
return getResponse().headers().allValues(LINK_HEADER)
.stream()
.map(p::matcher)
.flatMap(Matcher::results)
.map(m -> m.group(1))
.peek(location -> LOG.debug("Link: {} -> {}", relation, location))
.toList();
}
/**
* Resolves a relative link against the connection's last URL.
*
* @param link
* Link to resolve. Absolute links are just converted to an URL.
* @return Absolute URL of the given link
*/
private URL resolveRelative(String link) {
try {
return resolveUri(link).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
}
}
/**
* Resolves a relative URI against the connection's last URL.
*
* @param uri
* URI to resolve
* @return Absolute URI of the given link
*/
private URI resolveUri(String uri) {
return getResponse().request().uri().resolve(uri);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/HttpConnector.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import java.net.URISyntaxException;
import java.net.URL;
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;
/**
* A generic HTTP connector. It creates {@link HttpRequest.Builder} that can be
* individually customized according to the user's needs.
*
* @since 3.0.0
*/
public class HttpConnector {
private static final String USER_AGENT;
private final NetworkSettings networkSettings;
private final HttpClient httpClient;
static {
var agent = new StringBuilder("acme4j");
try (var in = HttpConnector.class.getResourceAsStream("/org/shredzone/acme4j/version.properties")) {
var prop = new Properties();
prop.load(in);
agent.append('/').append(prop.getProperty("version"));
} catch (Exception ex) {
// Ignore, just don't use a version
LoggerFactory.getLogger(HttpConnector.class).warn("Could not read library version", ex);
}
agent.append(" Java/").append(System.getProperty("java.version"));
USER_AGENT = agent.toString();
}
/**
* Returns the default User-Agent to be used.
*
* @return User-Agent
*/
public static String defaultUserAgent() {
return USER_AGENT;
}
/**
* Creates a new {@link HttpConnector} that is using the given
* {@link NetworkSettings} and {@link HttpClient}.
*
* @param networkSettings Network settings to use
* @param httpClient HTTP client to use for requests
* @since 4.0.0
*/
@SuppressFBWarnings("EI_EXPOSE_REP2") // behavior is intended
public HttpConnector(NetworkSettings networkSettings, HttpClient httpClient) {
this.networkSettings = networkSettings;
this.httpClient = httpClient;
}
/**
* Creates a new {@link HttpRequest.Builder} that is preconfigured and bound to the
* given URL. Subclasses can override this method to extend the configuration, or
* create a different builder.
*
* @param url
* {@link URL} to connect to
* @return {@link HttpRequest.Builder} connected to the {@link URL}
*/
public HttpRequest.Builder createRequestBuilder(URL url) {
try {
return HttpRequest.newBuilder(url.toURI())
.header("User-Agent", USER_AGENT)
.timeout(networkSettings.getTimeout());
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Invalid URL", ex);
}
}
/**
* Returns the {@link HttpClient} instance used by this connector.
*
* @return {@link HttpClient} instance
* @since 4.0.0
*/
public HttpClient getHttpClient() {
return httpClient;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/NetworkSettings.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2019 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.connector;
import java.net.Authenticator;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.slf4j.LoggerFactory;
/**
* Contains network settings to be used for network connections.
*
* @since 2.8
*/
public class NetworkSettings {
/**
* Name of the system property to control GZIP compression. Expects a boolean value.
*/
public static final String GZIP_PROPERTY_NAME = "org.shredzone.acme4j.gzip_compression";
private ProxySelector proxySelector = HttpClient.Builder.NO_PROXY;
private Duration timeout = Duration.ofSeconds(30);
private @Nullable Authenticator authenticator = null;
private boolean compression = true;
public NetworkSettings() {
try {
Optional.ofNullable(System.getProperty(GZIP_PROPERTY_NAME))
.map(Boolean::parseBoolean)
.ifPresent(val -> compression = val);
} catch (Exception ex) {
// Ignore a broken property name or a SecurityException
LoggerFactory.getLogger(NetworkSettings.class)
.warn("Could not read system property: {}", GZIP_PROPERTY_NAME, ex);
}
}
/**
* Gets the {@link ProxySelector} to be used for connections.
*
* @since 3.0.0
*/
public ProxySelector getProxySelector() {
return proxySelector;
}
/**
* Sets a {@link ProxySelector} that is to be used for all connections. If
* {@code null}, {@link HttpClient.Builder#NO_PROXY} is used, which is also the
* default.
*
* @since 3.0.0
*/
public void setProxySelector(@Nullable ProxySelector proxySelector) {
this.proxySelector = proxySelector != null ? proxySelector : HttpClient.Builder.NO_PROXY;
}
/**
* Gets the {@link Authenticator} to be used, or {@code null} if none is to be set.
*
* @since 3.0.0
*/
public @Nullable Authenticator getAuthenticator() {
return authenticator;
}
/**
* Sets an {@link Authenticator} to be used if HTTP authentication is needed (e.g.
* by a proxy). {@code null} means that no authenticator shall be set.
*
* @since 3.0.0
*/
public void setAuthenticator(@Nullable Authenticator authenticator) {
this.authenticator = authenticator;
}
/**
* Gets the current network timeout.
*/
public Duration getTimeout() {
return timeout;
}
/**
* Sets the network timeout to be used for connections. Defaults to 10 seconds.
*
* @param timeout
* Network timeout {@link Duration}
*/
public void setTimeout(Duration timeout) {
if (timeout == null || timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("Timeout must be positive");
}
this.timeout = timeout;
}
/**
* Checks if HTTP compression is enabled.
*
* @since 3.0.0
*/
public boolean isCompressionEnabled() {
return compression;
}
/**
* Sets if HTTP compression is enabled. It is enabled by default, but can be
* disabled e.g. for debugging purposes.
*
* acme4j gzip compression can also be controlled via the {@value #GZIP_PROPERTY_NAME}
* system property.
*
* @since 3.0.0
*/
public void setCompressionEnabled(boolean compression) {
this.compression = compression;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/NonceHolder.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2026 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.connector;
import edu.umd.cs.findbugs.annotations.Nullable;
/**
* Keeps the current nonce for a request. Make sure that the {@link #close()} method is
* always invoked, otherwise the related {@link org.shredzone.acme4j.Session} will be
* blocked.
*
* This object is for internal use only.
*
* @since 4.0.0
*/
public interface NonceHolder extends AutoCloseable {
/**
* Gets the last base64 encoded nonce, or {@code null} if the session is new.
*/
@Nullable
String getNonce();
/**
* Sets the base64 encoded nonce received by the server.
*/
void setNonce(@Nullable String nonce);
/**
* Closes the NonceHolder. Must be invoked!
*/
void close();
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/RequestSigner.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2026 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.connector;
import java.net.URL;
import java.security.KeyPair;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Function for assembling and signing an ACME JOSE request.
*
* @since 5.0.0
*/
@FunctionalInterface
public interface RequestSigner {
/**
* Creates an ACME JOSE request.
*
* Implementors can use
* {@link org.shredzone.acme4j.toolbox.JoseUtils#createJoseRequest(URL, KeyPair,
* JSONBuilder, String, String)} without giving the signing {@link KeyPair} out of
* their control.
*
* @param url
* {@link URL} of the ACME call
* @param payload
* ACME JSON payload. If {@code null}, a POST-as-GET request is generated
* instead.
* @param nonce
* Nonce to be used. {@code null} if no nonce is to be used in the JOSE
* header.
* @return JSON structure of the JOSE request, ready to be sent.
* @see org.shredzone.acme4j.toolbox.JoseUtils#createJoseRequest(URL, KeyPair,
* JSONBuilder, String, String)
*/
JSONBuilder createRequest(URL url, @Nullable JSONBuilder payload, @Nullable String nonce);
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
/**
* Enumeration of standard resources, and their key name in the CA's directory.
*/
public enum Resource {
NEW_NONCE("newNonce"),
NEW_ACCOUNT("newAccount"),
NEW_ORDER("newOrder"),
NEW_AUTHZ("newAuthz"),
REVOKE_CERT("revokeCert"),
KEY_CHANGE("keyChange"),
RENEWAL_INFO("renewalInfo");
private final String path;
Resource(String path) {
this.path = path;
}
/**
* Returns the key name in the directory.
*
* @return key name
*/
public String path() {
return path;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/ResourceIterator.java
================================================
/*
* 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.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.function.BiFunction;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.AcmeResource;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON;
/**
* An {@link Iterator} that fetches a batch of URLs from the ACME server, and generates
* {@link AcmeResource} instances.
*
* @param
* {@link AcmeResource} type to iterate over
*/
public class ResourceIterator implements Iterator {
private final Login login;
private final String field;
private final Deque urlList = new ArrayDeque<>();
private final BiFunction creator;
private boolean eol = false;
private @Nullable URL nextUrl;
/**
* Creates a new {@link ResourceIterator}.
*
* @param login
* {@link Login} to bind this iterator to
* @param field
* Field name to be used in the JSON response
* @param start
* URL of the first JSON array, may be {@code null} for an empty iterator
* @param creator
* Creator for an {@link AcmeResource} that is bound to the given
* {@link Login} and {@link URL}.
*/
public ResourceIterator(Login login, String field, @Nullable URL start, BiFunction creator) {
this.login = requireNonNull(login, "login");
this.field = requireNonNull(field, "field");
this.nextUrl = start;
this.creator = requireNonNull(creator, "creator");
}
/**
* Checks if there is another object in the result.
*
* @throws AcmeProtocolException
* if the next batch of URLs could not be fetched from the server
*/
@Override
public boolean hasNext() {
if (eol) {
return false;
}
if (urlList.isEmpty()) {
fetch();
}
if (urlList.isEmpty()) {
eol = true;
}
return !urlList.isEmpty();
}
/**
* Returns the next object of the result.
*
* @throws AcmeProtocolException
* if the next batch of URLs could not be fetched from the server
* @throws NoSuchElementException
* if there are no more entries
*/
@Override
public T next() {
if (!eol && urlList.isEmpty()) {
fetch();
}
var next = urlList.poll();
if (next == null) {
eol = true;
throw new NoSuchElementException("no more " + field);
}
return creator.apply(login, next);
}
/**
* Unsupported operation, only here to satisfy the {@link Iterator} interface.
*/
@Override
public void remove() {
throw new UnsupportedOperationException("cannot remove " + field);
}
/**
* Fetches the next batch of URLs. Handles exceptions. Does nothing if there is no
* URL of the next batch.
*/
private void fetch() {
if (nextUrl == null) {
return;
}
try {
readAndQueue();
} catch (AcmeException ex) {
throw new AcmeProtocolException("failed to read next set of " + field, ex);
}
}
/**
* Reads the next batch of URLs from the server, and fills the queue with the URLs. If
* there is a "next" header, it is used for the next batch of URLs.
*/
private void readAndQueue() throws AcmeException {
var session = login.getSession();
try (var conn = session.connect()) {
conn.sendSignedPostAsGetRequest(requireNonNull(nextUrl), login);
fillUrlList(conn.readJsonResponse());
nextUrl = conn.getLinks("next").stream().findFirst().orElse(null);
}
}
/**
* Fills the url list with the URLs found in the desired field.
*
* @param json
* JSON map to read from
*/
private void fillUrlList(JSON json) {
json.get(field).asArray().stream()
.map(JSON.Value::asURL)
.forEach(urlList::add);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/TrimmingInputStream.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.connector;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* Normalizes line separators in an InputStream. Converts all line separators to '\n'.
* Multiple line separators are compressed to a single line separator. Leading line
* separators are removed. Trailing line separators are compressed to a single separator.
*/
public class TrimmingInputStream extends InputStream {
private final BufferedInputStream in;
private boolean startOfFile = true;
/**
* Creates a new {@link TrimmingInputStream}.
*
* @param in
* {@link InputStream} to read from. Will be closed when this stream is
* closed.
*/
public TrimmingInputStream(InputStream in) {
this.in = new BufferedInputStream(in, 1024);
}
@Override
public int read() throws IOException {
var ch = in.read();
if (!isLineSeparator(ch)) {
startOfFile = false;
return ch;
}
in.mark(1);
ch = in.read();
while (isLineSeparator(ch)) {
in.mark(1);
ch = in.read();
}
if (startOfFile) {
startOfFile = false;
return ch;
} else {
in.reset();
return '\n';
}
}
@Override
public int available() throws IOException {
// Workaround for https://github.com/google/conscrypt/issues/1068. Conscrypt
// requires the stream to have at least one non-blocking byte available for
// reading, otherwise generateCertificates() will not read the stream, but
// immediately returns an empty list. This workaround pre-fills the buffer
// of the BufferedInputStream by reading 1 byte ahead.
if (in.available() == 0) {
in.mark(1);
var read = in.read();
in.reset();
if (read < 0) {
return 0;
}
}
return in.available();
}
@Override
public void close() throws IOException {
in.close();
super.close();
}
/**
* Checks if the character is a line separator.
*/
private static boolean isLineSeparator(int ch) {
return ch == '\n' || ch == '\r';
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/connector/package-info.java
================================================
/*
* 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 internal classes for connection to the CA, and for handling the
* requests and responses.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.connector;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeException.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.io.Serial;
/**
* The root class of all checked acme4j exceptions.
*/
public class AcmeException extends Exception {
@Serial
private static final long serialVersionUID = -2935088954705632025L;
/**
* Creates a generic {@link AcmeException}.
*/
public AcmeException() {
super();
}
/**
* Creates a generic {@link AcmeException}.
*
* @param msg
* Description
*/
public AcmeException(String msg) {
super(msg);
}
/**
* Creates a generic {@link AcmeException}.
*
* @param msg
* Description
* @param cause
* {@link Throwable} that caused this exception
*/
public AcmeException(String msg, Throwable cause) {
super(msg, cause);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeLazyLoadingException.java
================================================
/*
* 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 java.util.Objects.requireNonNull;
import java.io.Serial;
import java.net.URL;
import org.shredzone.acme4j.AcmeResource;
/**
* A runtime exception that is thrown when an {@link AcmeException} occured while trying
* to lazy-load a resource from the ACME server. It contains the original cause of the
* exception and a reference to the resource that could not be lazy-loaded. It is usually
* 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;
private final URL location;
/**
* Creates a new {@link AcmeLazyLoadingException}.
*
* @param resource
* {@link AcmeResource} to be loaded
* @param cause
* {@link AcmeException} that was raised
*/
public AcmeLazyLoadingException(AcmeResource resource, AcmeException cause) {
this(requireNonNull(resource).getClass(), requireNonNull(resource).getLocation(), cause);
}
/**
* Creates a new {@link AcmeLazyLoadingException}.
*
* This constructor is used if there is no actual instance of the resource.
*
* @param type
* {@link AcmeResource} type to be loaded
* @param location
* Resource location
* @param cause
* {@link AcmeException} that was raised
* @since 2.8
*/
public AcmeLazyLoadingException(Class extends AcmeResource> type, URL location, AcmeException cause) {
super(requireNonNull(type).getSimpleName() + " " + requireNonNull(location), requireNonNull(cause));
this.type = type;
this.location = location;
}
/**
* Returns the {@link AcmeResource} type of the resource that could not be loaded.
*/
public Class extends AcmeResource> getType() {
return type;
}
/**
* Returns the location of the resource that could not be loaded.
*/
public URL getLocation() {
return location;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeNetworkException.java
================================================
/*
* 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.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;
/**
* Create a new {@link AcmeNetworkException}.
*
* @param cause
* {@link IOException} that caused the network error
*/
public AcmeNetworkException(IOException cause) {
super("Network error", cause);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeNotSupportedException.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.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;
/**
* Creates a new {@link AcmeNotSupportedException}.
*
* @param feature
* Feature that is not supported
*/
public AcmeNotSupportedException(String feature) {
super("Server does not support " + feature);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeProtocolException.java
================================================
/*
* 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.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;
/**
* Creates a new {@link AcmeProtocolException}.
*
* @param msg
* Reason of the exception
*/
public AcmeProtocolException(String msg) {
super(msg);
}
/**
* Creates a new {@link AcmeProtocolException}.
*
* @param msg
* Reason of the exception
* @param cause
* Cause
*/
public AcmeProtocolException(String msg, Throwable cause) {
super(msg, cause);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRateLimitedException.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.io.Serial;
import java.net.URL;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Problem;
/**
* A rate limit was exceeded. If provided by the server, it also includes the earliest
* time at which a new attempt will be accepted, and a reference to a document that
* 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;
private final Collection documents;
/**
* Creates a new {@link AcmeRateLimitedException}.
*
* @param problem
* {@link Problem} that caused the exception
* @param retryAfter
* The instant of time that the request is expected to succeed again, may be
* {@code null} if not known
* @param documents
* URLs pointing to documents about the rate limit that was hit, may be
* {@code null} if not known
*/
public AcmeRateLimitedException(Problem problem, @Nullable Instant retryAfter,
@Nullable Collection documents) {
super(problem);
this.retryAfter = retryAfter;
this.documents = documents != null ? documents : Collections.emptyList();
}
/**
* Returns the instant of time the request is expected to succeed again. Empty
* if this moment is not known.
*/
public Optional getRetryAfter() {
return Optional.ofNullable(retryAfter);
}
/**
* Collection of URLs pointing to documents about the rate limit that was hit.
* Empty if the server did not provide such URLs.
*/
public Collection getDocuments() {
return Collections.unmodifiableCollection(documents);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeServerException.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.io.Serial;
import java.net.URI;
import java.util.Objects;
import org.shredzone.acme4j.Problem;
/**
* The ACME server returned an error. The exception contains a {@link Problem} document
* containing the exact cause of the error.
*
* For some special cases, subclasses of this exception are thrown, so they can be handled
* individually.
*/
public class AcmeServerException extends AcmeException {
@Serial
private static final long serialVersionUID = 5971622508467042792L;
private final Problem problem;
/**
* Creates a new {@link AcmeServerException}.
*
* @param problem
* {@link Problem} that caused the exception
*/
public AcmeServerException(Problem problem) {
super(Objects.requireNonNull(problem).toString());
this.problem = problem;
}
/**
* Returns the error type.
*/
public URI getType() {
return problem.getType();
}
/**
* Returns the {@link Problem} that caused the exception
*/
public Problem getProblem() {
return problem;
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeUnauthorizedException.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.io.Serial;
import org.shredzone.acme4j.Problem;
/**
* The client is not authorized to perform the operation. The {@link Problem} document
* will give further details (e.g. "client IP is blocked").
*/
public class AcmeUnauthorizedException extends AcmeServerException {
@Serial
private static final long serialVersionUID = 9064697508262919366L;
/**
* Creates a new {@link AcmeUnauthorizedException}.
*
* @param problem
* {@link Problem} that caused the exception
*/
public AcmeUnauthorizedException(Problem problem) {
super(problem);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeUserActionRequiredException.java
================================================
/*
* 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.io.Serial;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Problem;
/**
* The user is required to take manual action as indicated.
*
* Usually this exception is thrown when the terms of service have changed, and the CA
* 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;
/**
* Creates a new {@link AcmeUserActionRequiredException}.
*
* @param problem
* {@link Problem} that caused the exception
* @param tosUri
* {@link URI} of the terms-of-service document to accept, may be
* {@code null}
*/
public AcmeUserActionRequiredException(Problem problem, @Nullable URI tosUri) {
super(problem);
this.tosUri = tosUri;
}
/**
* Returns the {@link URI} of the terms-of-service document to accept. Empty
* if the server did not provide a link to such a document.
*/
public Optional getTermsOfServiceUri() {
return Optional.ofNullable(tosUri);
}
/**
* Returns the {@link URL} of a document that gives instructions on the actions to be
* taken by a human.
*/
public URL getInstance() {
var instance = getProblem().getInstance()
.orElseThrow(() -> new AcmeProtocolException("Instance URL required, but missing."));
try {
return instance.toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException("Bad instance URL: " + instance, ex);
}
}
@Override
public String toString() {
return getProblem().getInstance()
.map(uri -> "Please visit " + uri + " - details: " + getProblem())
.orElseGet(super::toString);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/exception/package-info.java
================================================
/*
* 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 all exceptions that can be thrown by acme4j.
*
* {@link org.shredzone.acme4j.exception.AcmeException} is the root exception, and other
* exceptions are derived from it.
*
* Some methods that do lazy-loading of remote resources may throw a runtime
* {@link org.shredzone.acme4j.exception.AcmeLazyLoadingException} instead, so the API is
* not polluted with checked exceptions on every getter.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.exception;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/package-info.java
================================================
/*
* 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.
*/
/**
* acme4j is a Java client for the ACME protocol.
*
* See the documentation and the example for how to use this client in your own projects.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.ServiceLoader;
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.DnsPersist01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.challenge.TokenChallenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.DefaultConnection;
import org.shredzone.acme4j.connector.HttpConnector;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Abstract implementation of {@link AcmeProvider}. It consists of a challenge
* registry and a standard {@link HttpConnector}.
*
* Implementing classes must implement at least {@link AcmeProvider#accepts(URI)}
* and {@link AbstractAcmeProvider#resolve(URI)}.
*/
public abstract class AbstractAcmeProvider implements AcmeProvider {
private static final int HTTP_NOT_MODIFIED = 304;
private static final Map CHALLENGES = challengeMap();
@Override
public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
return new DefaultConnection(createHttpConnector(networkSettings, httpClient));
}
@Override
public JSON directory(Session session, URI serverUri) throws AcmeException {
var expires = session.getDirectoryExpires();
if (expires != null && expires.isAfter(ZonedDateTime.now())) {
// The cached directory is still valid
return null;
}
try (var nonceHolder = session.lockNonce();
var conn = connect(serverUri, session.networkSettings(), session.getHttpClient())) {
var lastModified = session.getDirectoryLastModified();
var rc = conn.sendRequest(resolve(serverUri), session, lastModified);
if (lastModified != null && rc == HTTP_NOT_MODIFIED) {
// The server has not been modified since
return null;
}
// evaluate caching headers
session.setDirectoryLastModified(conn.getLastModified().orElse(null));
session.setDirectoryExpires(conn.getExpiration().orElse(null));
// use nonce header if there is one, saves a HEAD request...
conn.getNonce().ifPresent(nonceHolder::setNonce);
return conn.readJsonResponse();
}
}
private static Map challengeMap() {
var map = new HashMap();
map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new);
map.put(DnsPersist01Challenge.TYPE, DnsPersist01Challenge::new);
map.put(Http01Challenge.TYPE, Http01Challenge::new);
map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
for (var provider : ServiceLoader.load(ChallengeProvider.class)) {
var typeAnno = provider.getClass().getAnnotation(ChallengeType.class);
if (typeAnno == null) {
throw new IllegalStateException("ChallengeProvider "
+ provider.getClass().getName()
+ " has no @ChallengeType annotation");
}
var type = typeAnno.value();
if (type == null || type.trim().isEmpty()) {
throw new IllegalStateException("ChallengeProvider "
+ provider.getClass().getName()
+ ": type must not be null or empty");
}
if (map.containsKey(type)) {
throw new IllegalStateException("ChallengeProvider "
+ provider.getClass().getName()
+ ": there is already a provider for challenge type "
+ type);
}
map.put(type, provider);
}
return Collections.unmodifiableMap(map);
}
/**
* {@inheritDoc}
*
* This implementation handles the standard challenge types. For unknown types,
* generic {@link Challenge} or {@link TokenChallenge} instances are created.
*
* Custom provider implementations may override this method to provide challenges that
* are proprietary to the provider.
*/
@Override
public Challenge createChallenge(Login login, JSON data) {
Objects.requireNonNull(login, "login");
Objects.requireNonNull(data, "data");
var type = data.get("type").asString();
var constructor = CHALLENGES.get(type);
if (constructor != null) {
return constructor.create(login, data);
}
if (data.contains("token")) {
return new TokenChallenge(login, data);
} else {
return new Challenge(login, data);
}
}
/**
* Creates a {@link HttpConnector} with the given {@link NetworkSettings} and
* {@link HttpClient}.
*
* Subclasses may override this method to configure the {@link HttpConnector}.
*
* @param settings The network settings to use
* @param httpClient The HTTP client to use
* @return A new {@link HttpConnector} instance
* @since 4.0.0
*/
protected HttpConnector createHttpConnector(NetworkSettings settings, HttpClient httpClient) {
return new HttpConnector(settings, httpClient);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.util.Optional;
import java.util.ServiceLoader;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON;
/**
* An {@link AcmeProvider} provides methods to be used for communicating with the ACME
* server. Implementations handle individual features of each ACME server.
*
* Provider implementations must be registered with Java's {@link ServiceLoader}.
*/
public interface AcmeProvider {
/**
* Checks if this provider accepts the given server URI.
*
* @param serverUri
* Server URI to test
* @return {@code true} if this provider accepts the server URI, {@code false}
* otherwise
*/
boolean accepts(URI serverUri);
/**
* Resolves the server URI and returns the matching directory URL.
*
* @param serverUri
* Server {@link URI}
* @return Resolved directory {@link URL}
* @throws IllegalArgumentException
* if the server {@link URI} is not accepted
*/
URL resolve(URI serverUri);
/**
* Creates an {@link HttpClient} instance configured with the given network settings.
*
* The default implementation creates a standard HttpClient with the network settings.
* Subclasses can override this method to create a customized HttpClient, for example
* to configure SSL context or other provider-specific requirements.
*
* @param networkSettings The network settings to use
* @return {@link HttpClient} instance
* @since 4.0.0
*/
default HttpClient createHttpClient(NetworkSettings networkSettings) {
var builder = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(networkSettings.getTimeout())
.proxy(networkSettings.getProxySelector());
if (networkSettings.getAuthenticator() != null) {
builder.authenticator(networkSettings.getAuthenticator());
}
return builder.build();
}
/**
* Creates a {@link Connection} for communication with the ACME server.
*
* @param serverUri
* Server {@link URI}
* @param networkSettings
* {@link NetworkSettings} to be used for the connection
* @param httpClient
* {@link HttpClient} to be used for HTTP requests
* @return {@link Connection} that was generated
* @since 4.0.0
*/
Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient);
/**
* Returns the provider's directory. The structure must contain resource URLs, and may
* optionally contain metadata.
*
* The default implementation resolves the server URI and fetches the directory via
* HTTP request. Subclasses may override this method, e.g. if the directory is static.
*
* @param session
* {@link Session} to be used
* @param serverUri
* Server {@link URI}
* @return Directory data, as JSON object, or {@code null} if the directory has not
* been changed since the last request.
*/
@Nullable
JSON directory(Session session, URI serverUri) throws AcmeException;
/**
* Creates a {@link Challenge} instance for the given challenge data.
*
* @param login
* {@link Login} to bind the challenge to
* @param data
* Challenge {@link JSON} data
* @return {@link Challenge} instance, or {@code null} if this provider is unable to
* generate a matching {@link Challenge} instance.
*/
@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 getProposedEabMacAlgorithm() {
return Optional.empty();
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/ChallengeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2021 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;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.toolbox.JSON;
/**
* A provider that creates a Challenge from a matching JSON.
*
* @since 2.12
*/
@FunctionalInterface
public interface ChallengeProvider {
/**
* Creates a Challenge.
*
* @param login
* {@link Login} of the user's account
* @param data
* {@link JSON} of the challenge as sent by the CA
* @return Created and initialized {@link Challenge}. It must match the JSON type.
*/
Challenge create(Login login, JSON data);
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/ChallengeType.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2021 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;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotates the challenge type that is generated by the {@link ChallengeProvider}.
*
* @since 2.12
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ChallengeType {
/**
* Challenge type.
*/
String value();
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/GenericAcmeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
/**
* A generic {@link AcmeProvider}. It should be working for all ACME servers complying to
* the ACME specifications.
*
* The {@code serverUri} is either a http or https URI to the server's directory service.
*/
public class GenericAcmeProvider extends AbstractAcmeProvider {
@Override
public boolean accepts(URI serverUri) {
return "http".equals(serverUri.getScheme())
|| "https".equals(serverUri.getScheme());
}
@Override
public URL resolve(URI serverUri) {
try {
return serverUri.toURL();
} catch (MalformedURLException ex) {
throw new IllegalArgumentException("Bad generic server URI", ex);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/actalis/ActalisAcmeProvider.java
================================================
/*
* 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 Actalis .
*
* The {@code serverUri} is {@code "acme://actalis.com"} for the production server.
*
* If you want to use Actalis , always prefer to use this provider.
*
* @see Actalis S.p.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) meta);
if (metaMap.containsKey("home") && !metaMap.containsKey("website")) {
metaMap.put("website", metaMap.remove("home"));
}
}
return JSON.fromMap(directory);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/actalis/package-info.java
================================================
/*
* 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 Actalis S.p.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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/GoogleAcmeProvider.java
================================================
/*
* 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 Google Trust Services .
*
* The {@code serverUri} is {@code "acme://pki.goog"} for the production server,
* and {@code "acme://pki.goog/staging"} for the staging server.
*
* @see https://pki.goog/
* @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 getProposedEabMacAlgorithm() {
return Optional.of(AlgorithmIdentifiers.HMAC_SHA256);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/google/package-info.java
================================================
/*
* 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 https://pki.goog/
*/
@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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.letsencrypt;
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 Let's Encrypt .
*
* The {@code serverUri} is {@code "acme://letsencrypt.org"} for the production server,
* and {@code "acme://letsencrypt.org/staging"} for a testing server.
*
* If you want to use Let's Encrypt , always prefer to use this provider.
*
* @see Let's Encrypt
*/
public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
private static final String V02_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory";
private static final String STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "letsencrypt.org".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path) || "/v02".equals(path)) {
directoryUrl = V02_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);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/package-info.java
================================================
/*
* 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 Let's Encrypt
* {@link org.shredzone.acme4j.provider.AcmeProvider}.
*
* @see Let's Encrypt
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.letsencrypt;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/package-info.java
================================================
/*
* 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.
*/
/**
* Acme Providers are the link between acme4j and the ACME server. They know how to
* connect to their server, and how to set up HTTP connections.
*
* {@link org.shredzone.acme4j.provider.AcmeProvider} is the root interface.
* {@link org.shredzone.acme4j.provider.AbstractAcmeProvider} is an abstract
* implementation of the most elementary methods. Most HTTP based providers will extend
* from {@link org.shredzone.acme4j.provider.GenericAcmeProvider} though.
*
* Provider implementations must be registered with Java's
* {@link java.util.ServiceLoader}.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/pebble/PebbleAcmeProvider.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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.pebble;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.http.HttpClient;
import java.security.KeyManagementException;
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.Optional;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* An {@link AcmeProvider} for Pebble .
*
* Pebble is a small ACME test server.
* This provider can be used to connect to an instance of a Pebble server.
*
* {@code "acme://pebble"} connects to a Pebble server running on localhost and listening
* on the standard port 14000. Using {@code "acme://pebble/other-host:12345"}, it is
* possible to connect to an external Pebble server on the given {@code other-host} and
* port. The port is optional, and if omitted, the standard port is used.
*/
public class PebbleAcmeProvider extends AbstractAcmeProvider {
private static final Logger LOG = LoggerFactory.getLogger(PebbleAcmeProvider.class);
private static final Pattern HOST_PATTERN = Pattern.compile("^/([^:/]+)(?:\\:(\\d+))?/?$");
private static final int PEBBLE_DEFAULT_PORT = 14000;
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme()) && "pebble".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
try {
var path = serverUri.getPath();
int port = serverUri.getPort() != -1 ? serverUri.getPort() : PEBBLE_DEFAULT_PORT;
var baseUrl = URI.create("https://localhost:" + port + "/dir").toURL();
if (path != null && !path.isEmpty() && !"/".equals(path)) {
baseUrl = parsePath(path);
}
return baseUrl;
} catch (MalformedURLException ex) {
throw new IllegalArgumentException("Bad server URI " + serverUri, ex);
}
}
/**
* Parses the server URI path and returns the server's base URL.
*
* @param path
* server URI path
* @return URL of the server's base
*/
private URL parsePath(String path) throws MalformedURLException {
var m = HOST_PATTERN.matcher(path);
if (m.matches()) {
var host = m.group(1);
var port = PEBBLE_DEFAULT_PORT;
if (m.group(2) != null) {
port = Integer.parseInt(m.group(2));
}
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);
}
}
@Override
public HttpClient createHttpClient(NetworkSettings networkSettings) {
var builder = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(networkSettings.getTimeout())
.proxy(networkSettings.getProxySelector())
.sslContext(createPebbleSSLContext());
if (networkSettings.getAuthenticator() != null) {
builder.authenticator(networkSettings.getAuthenticator());
}
return builder.build();
}
/**
* Creates a TrustManagerFactory configured with the Pebble root certificate.
*
* This method loads the Pebble root certificate from the PEM file and creates
* a TrustManagerFactory that trusts certificates signed by Pebble's CA.
*
* @return TrustManagerFactory configured for Pebble
* @throws RuntimeException if the Pebble certificate cannot be found or loaded
* @since 4.0.0
*/
protected TrustManagerFactory createPebbleTrustManagerFactory() {
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);
return tmf;
} catch (KeyStoreException | NoSuchAlgorithmException ex) {
throw new RuntimeException("Could not create truststore", ex);
}
}
/**
* Creates the Pebble SSL context.
*
* Since the HTTP client is cached at the session level, this method is only called
* once per session, so no additional caching is needed.
*
* @return SSLContext configured for Pebble
*/
private SSLContext createPebbleSSLContext() {
try {
var tmf = createPebbleTrustManagerFactory();
var sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);
return sslContext;
} catch (NoSuchAlgorithmException | KeyManagementException ex) {
throw new RuntimeException("Could not create SSL context", ex);
}
}
/**
* Reads a PEM file from a resource for Pebble SSL context creation.
*/
private Optional readPemFile(String resource) {
try (var in = PebbleAcmeProvider.class.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();
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/pebble/package-info.java
================================================
/*
* 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 an {@link org.shredzone.acme4j.provider.AcmeProvider} for the
* Pebble test server.
*
* @see Pebble project page
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.pebble;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProvider.java
================================================
/*
* 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 SSL.com .
*
* The {@code serverUri} is {@code "acme://ssl.com"} for the production server,
* and {@code "acme://acme-try.ssl.com"} for a testing server.
*
* If you want to use SSL.com , always prefer to use this provider.
*
* @see SSL.com
* @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) meta);
metaMap.remove("externalAccountRequired");
metaMap.put("externalAccountRequired", true);
}
return JSON.fromMap(directory);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/package-info.java
================================================
/*
* 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 SSL.com
*/
@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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/zerossl/ZeroSSLAcmeProvider.java
================================================
/*
* 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 ZeroSSL .
*
* The {@code serverUri} is {@code "acme://zerossl.com"} for the production server.
*
* @see ZeroSSL
* @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);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/provider/zerossl/package-info.java
================================================
/*
* 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 ZeroSSL
*/
@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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/AcmeUtils.java
================================================
/*
* 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.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;
/**
* Contains utility methods that are frequently used for the ACME protocol.
*
* This class is internal. You may use it in your own code, but be warned that methods may
* change their signature or disappear without prior announcement.
*/
public final class AcmeUtils {
private static final char[] HEX = "0123456789abcdef".toCharArray();
private static final String ACME_ERROR_PREFIX = "urn:ietf:params:acme:error:";
private static final Pattern DATE_PATTERN = Pattern.compile(
"^(\\d{4})-(\\d{2})-(\\d{2})T"
+ "(\\d{2}):(\\d{2}):(\\d{2})"
+ "(?:\\.(\\d{1,3})\\d*)?"
+ "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE);
private static final Pattern TZ_PATTERN = Pattern.compile(
"([+-])(\\d{2}):?(\\d{2})$");
private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(
"([^;]+)(?:;.*?charset=(\"?)([a-z0-9_-]+)(\\2))?.*", Pattern.CASE_INSENSITIVE);
private static final Pattern MAIL_PATTERN = Pattern.compile("\\?|@.*,");
private static final Pattern BASE64URL_PATTERN = Pattern.compile("[0-9A-Za-z_-]*");
private static final Base64.Encoder PEM_ENCODER = Base64.getMimeEncoder(64,
"\n".getBytes(StandardCharsets.US_ASCII));
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.
*/
public enum PemLabel {
CERTIFICATE("CERTIFICATE"),
CERTIFICATE_REQUEST("CERTIFICATE REQUEST"),
PRIVATE_KEY("PRIVATE KEY"),
PUBLIC_KEY("PUBLIC KEY");
private final String label;
PemLabel(String label) {
this.label = label;
}
@Override
public String toString() {
return label;
}
}
private AcmeUtils() {
// Utility class without constructor
}
/**
* Computes a SHA-256 hash of the given string.
*
* @param z
* String to hash
* @return Hash
*/
public static byte[] sha256hash(String z) {
try {
var md = MessageDigest.getInstance("SHA-256");
md.update(z.getBytes(UTF_8));
return md.digest();
} catch (NoSuchAlgorithmException ex) {
throw new AcmeProtocolException("Could not compute hash", ex);
}
}
/**
* Hex encodes the given byte array.
*
* @param data
* byte array to hex encode
* @return Hex encoded string of the data (with lower case characters)
*/
public static String hexEncode(byte[] data) {
var result = new char[data.length * 2];
for (var ix = 0; ix < data.length; ix++) {
var val = data[ix] & 0xFF;
result[ix * 2] = HEX[val >>> 4];
result[ix * 2 + 1] = HEX[val & 0x0F];
}
return new String(result);
}
/**
* Base64 encodes the given byte array, using URL style encoding.
*
* @param data
* byte array to base64 encode
* @return base64 encoded string
*/
public static String base64UrlEncode(byte[] data) {
return URL_ENCODER.encodeToString(data);
}
/**
* Base64 decodes to a byte array, using URL style encoding.
*
* @param base64
* base64 encoded string
* @return decoded data
*/
public static byte[] base64UrlDecode(String base64) {
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.
*
* @param base64
* {@link String} to validate
* @return {@code true}: String contains a valid base64url encoded value.
* {@code false} if the {@link String} was {@code null} or contained illegal
* characters.
* @since 2.6
*/
public static boolean isValidBase64Url(@Nullable String base64) {
return base64 != null && BASE64URL_PATTERN.matcher(base64).matches();
}
/**
* ASCII encodes a domain name.
*
* The conversion is done as described in
* RFC 3490 . Additionally, all
* leading and trailing white spaces are trimmed, and the result is lowercased.
*
* It is safe to pass in ACE encoded domains, they will be returned unchanged.
*
* @param domain
* Domain name to encode
* @return Encoded domain name, white space trimmed and lower cased.
*/
public static String toAce(String domain) {
Objects.requireNonNull(domain, "domain");
return IDN.toASCII(domain.trim()).toLowerCase(Locale.ENGLISH);
}
/**
* Parses a RFC 3339 formatted date.
*
* @param str
* Date string
* @return {@link Instant} that was parsed
* @throws IllegalArgumentException
* if the date string was not RFC 3339 formatted
* @see RFC 3339
*/
public static Instant parseTimestamp(String str) {
var m = DATE_PATTERN.matcher(str);
if (!m.matches()) {
throw new IllegalArgumentException("Illegal date: " + str);
}
var year = Integer.parseInt(m.group(1));
var month = Integer.parseInt(m.group(2));
var dom = Integer.parseInt(m.group(3));
var hour = Integer.parseInt(m.group(4));
var minute = Integer.parseInt(m.group(5));
var second = Integer.parseInt(m.group(6));
var msStr = new StringBuilder();
if (m.group(7) != null) {
msStr.append(m.group(7));
}
while (msStr.length() < 3) {
msStr.append('0');
}
var ms = Integer.parseInt(msStr.toString());
var tz = m.group(8);
if ("Z".equalsIgnoreCase(tz)) {
tz = "GMT";
} else {
tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3");
}
return ZonedDateTime.of(
year, month, dom, hour, minute, second, ms * 1_000_000,
ZoneId.of(tz)).toInstant();
}
/**
* Converts the given locale to an Accept-Language header value.
*
* @param locale
* {@link Locale} to be used in the header
* @return Value that can be used in an Accept-Language header
*/
public static String localeToLanguageHeader(@Nullable Locale locale) {
if (locale == null || "und".equals(locale.toLanguageTag())) {
return "*";
}
var langTag = locale.toLanguageTag();
var header = new StringBuilder(langTag);
if (langTag.indexOf('-') >= 0) {
header.append(',').append(locale.getLanguage()).append(";q=0.8");
}
header.append(",*;q=0.1");
return header.toString();
}
/**
* Strips the acme error prefix from the error string.
*
* For example, for "urn:ietf:params:acme:error:unauthorized", "unauthorized" is
* returned.
*
* @param type
* Error type to strip the prefix from. {@code null} is safe.
* @return Stripped error type, or {@code null} if the prefix was not found.
*/
@Nullable
public static String stripErrorPrefix(@Nullable String type) {
if (type != null && type.startsWith(ACME_ERROR_PREFIX)) {
return type.substring(ACME_ERROR_PREFIX.length());
} else {
return null;
}
}
/**
* Writes an encoded key or certificate to a file in PEM format.
*
* @param encoded
* Encoded data to write
* @param label
* {@link PemLabel} to be used
* @param out
* {@link Writer} to write to. It will not be closed after use!
*/
public static void writeToPem(byte[] encoded, PemLabel label, Writer out)
throws IOException {
out.append("-----BEGIN ").append(label.toString()).append("-----\n");
out.append(new String(PEM_ENCODER.encode(encoded), StandardCharsets.US_ASCII));
out.append("\n-----END ").append(label.toString()).append("-----\n");
}
/**
* Extracts the content type of a Content-Type header.
*
* @param header
* Content-Type header
* @return Content-Type, or {@code null} if the header was invalid or empty
* @throws AcmeProtocolException
* if the Content-Type header contains a different charset than "utf-8".
*/
@Nullable
public static String getContentType(@Nullable String header) {
if (header != null) {
var m = CONTENT_TYPE_PATTERN.matcher(header);
if (m.matches()) {
var charset = m.group(3);
if (charset != null && !"utf-8".equalsIgnoreCase(charset)) {
throw new AcmeProtocolException("Unsupported charset " + charset);
}
return m.group(1).trim().toLowerCase(Locale.ENGLISH);
}
}
return null;
}
/**
* Validates a contact {@link URI}.
*
* @param contact
* Contact {@link URI} to validate
* @throws IllegalArgumentException
* if the contact {@link URI} is not suitable for account contacts.
*/
public static void validateContact(URI contact) {
if ("mailto".equalsIgnoreCase(contact.getScheme())) {
var address = contact.toString().substring(7);
if (MAIL_PATTERN.matcher(address).find()) {
throw new IllegalArgumentException(
"multiple recipients or hfields are not allowed: " + contact);
}
}
}
/**
* 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);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSON.java
================================================
/*
* 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.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Serial;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.json.JsonUtil;
import org.jose4j.lang.JoseException;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
/**
* A model containing a JSON result. The content is immutable.
*/
public final class JSON implements Serializable {
@Serial
private static final long serialVersionUID = 418332625174149030L;
private static final JSON EMPTY_JSON = new JSON(new HashMap<>());
private final String path;
private final Map data;
/**
* Creates a new {@link JSON} root object.
*
* @param data
* {@link Map} containing the parsed JSON data
*/
private JSON(Map data) {
this("", data);
}
/**
* Creates a new {@link JSON} branch object.
*
* @param path
* Path leading to this branch.
* @param data
* {@link Map} containing the parsed JSON data
*/
private JSON(String path, Map data) {
this.path = path;
this.data = data;
}
/**
* Parses JSON from an {@link InputStream}.
*
* @param in
* {@link InputStream} to read from. Will be closed after use.
* @return {@link JSON} of the read content.
*/
public static JSON parse(InputStream in) throws IOException {
try (var reader = new BufferedReader(new InputStreamReader(in, UTF_8))) {
var json = reader.lines().map(String::trim).collect(joining());
return parse(json);
}
}
/**
* Parses JSON from a String.
*
* @param json
* JSON string
* @return {@link JSON} of the read content.
*/
public static JSON parse(String json) {
try {
return new JSON(JsonUtil.parseJson(json));
} catch (JoseException ex) {
throw new AcmeProtocolException("Bad JSON: " + json, ex);
}
}
/**
* Creates a JSON object from a map.
*
* 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 data) {
return JSON.parse(JsonUtil.toJson(data));
}
/**
* Returns a {@link JSON} of an empty document.
*
* @return Empty {@link JSON}
*/
public static JSON empty() {
return EMPTY_JSON;
}
/**
* Returns a set of all keys of this object.
*
* @return {@link Set} of keys
*/
public Set keySet() {
return Collections.unmodifiableSet(data.keySet());
}
/**
* Checks if this object contains the given key.
*
* @param key
* Name of the key to check
* @return {@code true} if the key is present
*/
public boolean contains(String key) {
return data.containsKey(key);
}
/**
* Returns the {@link Value} of the given key.
*
* @param key
* Key to read
* @return {@link Value} of the key
*/
public Value get(String key) {
return new Value(
path.isEmpty() ? key : path + '.' + key,
data.get(key));
}
/**
* Returns the {@link Value} of the given key.
*
* @param key
* Key to read
* @return {@link Value} of the key
* @throws AcmeNotSupportedException
* if the key is not present. The key is used as feature name.
*/
public Value getFeature(String key) {
return new Value(
path.isEmpty() ? key : path + '.' + key,
data.get(key)).onFeature(key);
}
/**
* Returns the content as JSON string.
*/
@Override
public String toString() {
return JsonUtil.toJson(data);
}
/**
* Returns the content as unmodifiable Map.
*
* @since 2.8
*/
public Map toMap() {
return Collections.unmodifiableMap(data);
}
/**
* Represents a JSON array.
*/
public static final class Array implements Iterable {
private final String path;
private final List data;
/**
* Creates a new {@link Array} object.
*
* @param path
* JSON path to this array.
* @param data
* Array data
*/
private Array(String path, List data) {
this.path = path;
this.data = data;
}
/**
* Returns the array size.
*
* @return Size of the array
*/
public int size() {
return data.size();
}
/**
* Returns {@code true} if the array is empty.
*/
public boolean isEmpty() {
return data.isEmpty();
}
/**
* Gets the {@link Value} at the given index.
*
* @param index
* Array index to read from
* @return {@link Value} at this index
*/
public Value get(int index) {
return new Value(path + '[' + index + ']', data.get(index));
}
/**
* Returns a stream of values.
*
* @return {@link Stream} of all {@link Value} of this array
*/
public Stream stream() {
return StreamSupport.stream(spliterator(), false);
}
/**
* Creates a new {@link Iterator} that iterates over the array {@link Value}.
*/
@Override
public Iterator iterator() {
return new ValueIterator(this);
}
}
/**
* A single JSON value. This instance also covers {@code null} values.
*
* All return values are never {@code null} unless specified otherwise. For optional
* parameters, use {@link Value#optional()}.
*/
public static final class Value {
private final String path;
private final @Nullable Object val;
/**
* Creates a new {@link Value}.
*
* @param path
* JSON path to this value
* @param val
* Value, may be {@code null}
*/
private Value(String path, @Nullable Object val) {
this.path = path;
this.val = val;
}
/**
* Checks if this value is {@code null}.
*
* @return {@code true} if this value is present, {@code false} if {@code null}.
*/
public boolean isPresent() {
return val != null;
}
/**
* Returns this value as {@link Optional}, for further mapping and filtering.
*
* @return {@link Optional} of this value, or {@link Optional#empty()} if this
* value is {@code null}.
* @see #map(Function)
*/
public Optional optional() {
return val != null ? Optional.of(this) : Optional.empty();
}
/**
* Returns this value. If the value was {@code null}, an
* {@link AcmeNotSupportedException} is thrown. This method is used for mandatory
* fields that are only present if a certain feature is supported by the server.
*
* @param feature
* Feature name
* @return itself
*/
public Value onFeature(String feature) {
if (val == null) {
throw new AcmeNotSupportedException(feature);
}
return this;
}
/**
* Returns this value as an {@link Optional} of the desired type, for further
* mapping and filtering.
*
* @param mapper
* A {@link Function} that converts a {@link Value} to the desired type
* @return {@link Optional} of this value, or {@link Optional#empty()} if this
* value is {@code null}.
* @see #optional()
*/
public Optional map(Function mapper) {
return optional().map(mapper);
}
/**
* Returns the value as {@link String}.
*/
public String asString() {
return required().toString();
}
/**
* Returns the value as JSON object.
*/
@SuppressWarnings("unchecked")
public JSON asObject() {
return new JSON(path, (Map) required(Map.class));
}
/**
* Returns the value as JSON object that was Base64 URL encoded.
*
* @since 2.8
*/
public JSON asEncodedObject() {
try {
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);
}
}
/**
* Returns the value as {@link Problem}.
*
* @param baseUrl
* Base {@link URL} to resolve relative links against
*/
public Problem asProblem(URL baseUrl) {
return new Problem(asObject(), baseUrl);
}
/**
* Returns the value as {@link Identifier}.
*
* @since 2.3
*/
public Identifier asIdentifier() {
return new Identifier(asObject());
}
/**
* Returns the value as {@link JSON.Array}.
*
* 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());
}
try {
return new Array(path, (List) val);
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": expected an array", ex);
}
}
/**
* Returns the value as int.
*/
public int asInt() {
return (required(Number.class)).intValue();
}
/**
* Returns the value as boolean.
*/
public boolean asBoolean() {
return required(Boolean.class);
}
/**
* Returns the value as {@link URI}.
*/
public URI asURI() {
try {
return new URI(asString());
} catch (URISyntaxException ex) {
throw new AcmeProtocolException(path + ": bad URI " + val, ex);
}
}
/**
* Returns the value as {@link URL}.
*/
public URL asURL() {
try {
return asURI().toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(path + ": bad URL " + val, ex);
}
}
/**
* Returns the value as {@link Instant}.
*/
public Instant asInstant() {
try {
return parseTimestamp(asString());
} catch (IllegalArgumentException ex) {
throw new AcmeProtocolException(path + ": bad date " + val, ex);
}
}
/**
* Returns the value as {@link Duration}.
*
* @since 2.3
*/
public Duration asDuration() {
return Duration.ofSeconds(required(Number.class).longValue());
}
/**
* Returns the value as base64 decoded byte array.
*/
public byte[] asBinary() {
return AcmeUtils.base64UrlDecode(asString());
}
/**
* Returns the parsed {@link Status}.
*/
public Status asStatus() {
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 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 required(Class 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
public boolean equals(Object obj) {
if (!(obj instanceof Value)) {
return false;
}
return Objects.equals(val, ((Value) obj).val);
}
@Override
public int hashCode() {
return val != null ? val.hashCode() : 0;
}
}
/**
* An {@link Iterator} over array {@link Value}.
*/
private static class ValueIterator implements Iterator {
private final Array array;
private int index = 0;
public ValueIterator(Array array) {
this.array = array;
}
@Override
public boolean hasNext() {
return index < array.size();
}
@Override
public Value next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return array.get(index++);
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JSONBuilder.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.toolbox;
import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
import java.security.Key;
import java.security.PublicKey;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.json.JsonUtil;
/**
* Builder for JSON structures.
*
* Example:
*
* JSONBuilder cb = new JSONBuilder();
* cb.put("foo", 123).put("bar", "hello world");
* cb.object("sub").put("data", "subdata");
* cb.array("array", 123, 456, 789);
*
*/
public class JSONBuilder {
private final Map data = new LinkedHashMap<>();
/**
* Puts a property. If a property with the key exists, it will be replaced.
*
* @param key
* Property key
* @param value
* Property value
* @return {@code this}
*/
public JSONBuilder put(String key, @Nullable Object value) {
data.put(Objects.requireNonNull(key, "key"), value);
return this;
}
/**
* Puts an {@link Instant} to the JSON. If a property with the key exists, it will be
* replaced.
*
* @param key
* Property key
* @param value
* Property {@link Instant} value
* @return {@code this}
*/
public JSONBuilder put(String key, @Nullable Instant value) {
if (value == null) {
put(key, (Object) null);
return this;
}
put(key, DateTimeFormatter.ISO_INSTANT.format(value));
return this;
}
/**
* Puts a {@link Duration} to the JSON. If a property with the key exists, it will be
* replaced.
*
* @param key
* Property key
* @param value
* Property {@link Duration} value
* @return {@code this}
* @since 2.3
*/
public JSONBuilder put(String key, @Nullable Duration value) {
if (value == null) {
put(key, (Object) null);
return this;
}
put(key, value.getSeconds());
return this;
}
/**
* Puts binary data to the JSON. The data is base64 url encoded.
*
* @param key
* Property key
* @param data
* Property data
* @return {@code this}
*/
public JSONBuilder putBase64(String key, byte[] data) {
return put(key, base64UrlEncode(data));
}
/**
* Puts a {@link Key} into the claim. The key is serializied as JWK.
*
* @param key
* Property key
* @param publickey
* {@link PublicKey} to serialize
* @return {@code this}
*/
public JSONBuilder putKey(String key, PublicKey publickey) {
Objects.requireNonNull(publickey, "publickey");
data.put(key, JoseUtils.publicKeyToJWK(publickey));
return this;
}
/**
* Creates an object for the given key.
*
* @param key
* Key of the object
* @return Newly created {@link JSONBuilder} for the object.
*/
public JSONBuilder object(String key) {
var subBuilder = new JSONBuilder();
data.put(key, subBuilder.data);
return subBuilder;
}
/**
* Puts an array.
*
* @param key
* Property key
* @param values
* Collection of property values
* @return {@code this}
*/
public JSONBuilder array(String key, Collection> values) {
data.put(key, values);
return this;
}
/**
* Returns a {@link Map} representation of the current state.
*
* @return {@link Map} of the current state
*/
public Map toMap() {
return Collections.unmodifiableMap(data);
}
/**
* Returns a {@link JSON} representation of the current state.
*
* @return {@link JSON} of the current state
*/
public JSON toJSON() {
return JSON.parse(toString());
}
/**
* Returns a JSON string representation of the current state.
*/
@Override
public String toString() {
return JsonUtil.toJson(data);
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/JoseUtils.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2019 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.toolbox;
import java.net.URL;
import java.security.KeyPair;
import java.security.PublicKey;
import java.util.Map;
import javax.crypto.SecretKey;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.jwk.EllipticCurveJsonWebKey;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.PublicJsonWebKey;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.lang.JoseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utility class that takes care of all the JOSE stuff.
*
* Internal class, do not use in your project! The API may change anytime, in a breaking
* manner, and without prior notice.
*
* @since 2.7
*/
public final class JoseUtils {
private static final Logger LOG = LoggerFactory.getLogger(JoseUtils.class);
private JoseUtils() {
// Utility class without constructor
}
/**
* Creates an ACME JOSE request.
*
* @param url
* {@link URL} of the ACME call
* @param keypair
* {@link KeyPair} to sign the request with
* @param payload
* ACME JSON payload. If {@code null}, a POST-as-GET request is generated
* instead.
* @param nonce
* Nonce to be used. {@code null} if no nonce is to be used in the JOSE
* header.
* @param kid
* kid to be used in the JOSE header. If {@code null}, a jwk header of the
* given key is used instead.
* @return JSON structure of the JOSE request, ready to be sent.
*/
public static JSONBuilder createJoseRequest(URL url, KeyPair keypair,
@Nullable JSONBuilder payload, @Nullable String nonce, @Nullable String kid) {
try {
var jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic());
var jws = new JsonWebSignature();
jws.getHeaders().setObjectHeaderValue("url", url);
if (kid != null) {
jws.getHeaders().setObjectHeaderValue("kid", kid);
} else {
jws.getHeaders().setJwkHeaderValue("jwk", jwk);
}
if (nonce != null) {
jws.getHeaders().setObjectHeaderValue("nonce", nonce);
}
jws.setPayload(payload != null ? payload.toString() : "");
jws.setAlgorithmHeaderValue(keyAlgorithm(jwk));
jws.setKey(keypair.getPrivate());
jws.sign();
if (LOG.isDebugEnabled()) {
LOG.debug("{} {}", payload != null ? "POST" : "POST-as-GET", url);
if (payload != null) {
LOG.debug(" Payload: {}", payload);
}
LOG.debug(" JWS Header: {}", jws.getHeaders().getFullHeaderAsJsonString());
}
var jb = new JSONBuilder();
jb.put("protected", jws.getHeaders().getEncodedHeader());
jb.put("payload", jws.getEncodedPayload());
jb.put("signature", jws.getEncodedSignature());
return jb;
} catch (JoseException ex) {
throw new IllegalArgumentException("Could not create a JOSE request", ex);
}
}
/**
* Creates a JSON structure for external account binding.
*
* @param kid
* Key Identifier provided by the CA
* @param accountKey
* {@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 createExternalAccountBinding(String kid,
PublicKey accountKey, SecretKey macKey, String macAlgorithm, URL resource) {
try {
var keyJwk = PublicJsonWebKey.Factory.newPublicJwk(accountKey);
var innerJws = new JsonWebSignature();
innerJws.setPayload(keyJwk.toJson());
innerJws.getHeaders().setObjectHeaderValue("url", resource);
innerJws.getHeaders().setObjectHeaderValue("kid", kid);
innerJws.setAlgorithmHeaderValue(macAlgorithm);
innerJws.setKey(macKey);
innerJws.setDoKeyValidation(false);
innerJws.sign();
var outerClaim = new JSONBuilder();
outerClaim.put("protected", innerJws.getHeaders().getEncodedHeader());
outerClaim.put("signature", innerJws.getEncodedSignature());
outerClaim.put("payload", innerJws.getEncodedPayload());
return outerClaim.toMap();
} catch (JoseException ex) {
throw new IllegalArgumentException("Could not create external account binding", ex);
}
}
/**
* Converts a {@link PublicKey} to a JOSE JWK structure.
*
* @param key
* {@link PublicKey} to convert
* @return JSON map containing the JWK structure
*/
public static Map publicKeyToJWK(PublicKey key) {
try {
return PublicJsonWebKey.Factory.newPublicJwk(key)
.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY);
} catch (JoseException ex) {
throw new IllegalArgumentException("Bad public key", ex);
}
}
/**
* Converts a JOSE JWK structure to a {@link PublicKey}.
*
* @param jwk
* Map containing a JWK structure
* @return the extracted {@link PublicKey}
*/
public static PublicKey jwkToPublicKey(Map jwk) {
try {
return PublicJsonWebKey.Factory.newPublicJwk(jwk).getPublicKey();
} catch (JoseException ex) {
throw new IllegalArgumentException("Bad JWK", ex);
}
}
/**
* Computes a thumbprint of the given public key.
*
* @param key
* {@link PublicKey} to get the thumbprint of
* @return Thumbprint of the key
*/
public static byte[] thumbprint(PublicKey key) {
try {
var jwk = PublicJsonWebKey.Factory.newPublicJwk(key);
return jwk.calculateThumbprint("SHA-256");
} catch (JoseException ex) {
throw new IllegalArgumentException("Bad public key", ex);
}
}
/**
* Analyzes the key used in the {@link JsonWebKey}, and returns the key algorithm
* identifier for {@link JsonWebSignature}.
*
* @param jwk
* {@link JsonWebKey} to analyze
* @return algorithm identifier
* @throws IllegalArgumentException
* there is no corresponding algorithm identifier for the key
*/
public static String keyAlgorithm(JsonWebKey jwk) {
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;
} else {
throw new IllegalArgumentException("Unknown algorithm " + jwk.getAlgorithm());
}
}
/**
* Analyzes the {@link SecretKey}, and returns the key algorithm identifier for {@link
* JsonWebSignature}.
*
* @param macKey
* {@link SecretKey} to analyze
* @return algorithm identifier
* @throws IllegalArgumentException
* there is no corresponding algorithm identifier for the key
*/
public static String macKeyAlgorithm(SecretKey macKey) {
if (!"HMAC".equals(macKey.getAlgorithm())) {
throw new IllegalArgumentException("Bad algorithm: " + macKey.getAlgorithm());
}
var size = macKey.getEncoded().length * 8;
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;
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/toolbox/package-info.java
================================================
/*
* 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.
*/
/**
* Internal toolbox. The API of these classes may change anytime, in a breaking manner,
* and without prior notice. It is better not to use them in your project.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.toolbox;
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;
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/util/CSRBuilder.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.InetAddress;
import java.security.KeyPair;
import java.security.interfaces.ECKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.X500NameBuilder;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.ExtensionsGenerator;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.shredzone.acme4j.Identifier;
/**
* Generator for a CSR (Certificate Signing Request) suitable for ACME servers.
*
* Requires {@code Bouncy Castle}. The
* {@link org.bouncycastle.jce.provider.BouncyCastleProvider} must be added as security
* provider.
*/
public class CSRBuilder {
private static final String SIGNATURE_ALG = "SHA256withRSA";
private static final String EC_SIGNATURE_ALG = "SHA256withECDSA";
private final X500NameBuilder namebuilder = new X500NameBuilder(X500Name.getDefaultStyle());
private final List namelist = new ArrayList<>();
private final List iplist = new ArrayList<>();
private @Nullable PKCS10CertificationRequest csr = null;
private boolean hasCnSet = false;
/**
* Adds a domain name to the CSR. All domain names will be added as Subject
* Alternative Name .
*
* IDN domain names are ACE encoded automatically.
*
* For wildcard certificates, the domain name must be prefixed with {@code "*."}.
*
* @param domain
* Domain name to add
*/
public void addDomain(String domain) {
namelist.add(toAce(requireNonNull(domain)));
}
/**
* Adds a {@link Collection} of domains.
*
* IDN domain names are ACE encoded automatically.
*
* @param domains
* Collection of domain names to add
*/
public void addDomains(Collection domains) {
domains.forEach(this::addDomain);
}
/**
* Adds multiple domain names.
*
* IDN domain names are ACE encoded automatically.
*
* @param domains
* Domain names to add
*/
public void addDomains(String... domains) {
Arrays.stream(domains).forEach(this::addDomain);
}
/**
* Adds an {@link InetAddress}. All IP addresses will be set as iPAddress Subject
* Alternative Name .
*
* @param address
* {@link InetAddress} to add
* @since 2.4
*/
public void addIP(InetAddress address) {
iplist.add(requireNonNull(address));
}
/**
* Adds a {@link Collection} of IP addresses.
*
* @param ips
* Collection of IP addresses to add
* @since 2.4
*/
public void addIPs(Collection ips) {
ips.forEach(this::addIP);
}
/**
* Adds multiple IP addresses.
*
* @param ips
* IP addresses to add
* @since 2.4
*/
public void addIPs(InetAddress... ips) {
Arrays.stream(ips).forEach(this::addIP);
}
/**
* Adds an {@link Identifier}. Only DNS and IP types are supported.
*
* @param id
* {@link Identifier} to add
* @since 2.7
*/
public void addIdentifier(Identifier id) {
requireNonNull(id);
if (Identifier.TYPE_DNS.equals(id.getType())) {
addDomain(id.getDomain());
} else if (Identifier.TYPE_IP.equals(id.getType())) {
addIP(id.getIP());
} else {
throw new IllegalArgumentException("Unknown identifier type: " + id.getType());
}
}
/**
* Adds a {@link Collection} of {@link Identifier}.
*
* @param ids
* Collection of Identifiers to add
* @since 2.7
*/
public void addIdentifiers(Collection ids) {
ids.forEach(this::addIdentifier);
}
/**
* Adds multiple {@link Identifier}.
*
* @param ids
* Identifiers to add
* @since 2.7
*/
public void addIdentifiers(Identifier... ids) {
Arrays.stream(ids).forEach(this::addIdentifier);
}
/**
* Sets an entry of the subject used for the CSR.
*
* This method is meant as "expert mode" for setting attributes that are not covered
* by the other methods. It is at the discretion of the ACME server to accept this
* parameter.
*
* @param attName
* The BCStyle attribute name
* @param value
* The value
* @since 2.14
*/
public void addValue(String attName, String value) {
var oid = X500Name.getDefaultStyle().attrNameToOID(requireNonNull(attName, "attribute name must not be null"));
addValue(oid, value);
}
/**
* Sets an entry of the subject used for the CSR.
*
* This method is meant as "expert mode" for setting attributes that are not covered
* by the other methods. It is at the discretion of the ACME server to accept this
* parameter.
*
* @param oid
* The OID of the attribute to be added
* @param value
* The value
* @since 2.14
*/
public void addValue(ASN1ObjectIdentifier oid, String value) {
if (requireNonNull(oid, "OID must not be null").equals(BCStyle.CN)) {
addDomain(value);
if (hasCnSet) {
return;
}
hasCnSet = true;
}
namebuilder.addRDN(oid, requireNonNull(value, "attribute value must not be null"));
}
/**
* Sets the common name.
*
* 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.
*
* Note that it is at the discretion of the ACME server to accept this parameter.
*/
public void setOrganization(String o) {
namebuilder.addRDN(BCStyle.O, requireNonNull(o));
}
/**
* Sets the organizational unit.
*
* Note that it is at the discretion of the ACME server to accept this parameter.
*/
public void setOrganizationalUnit(String ou) {
namebuilder.addRDN(BCStyle.OU, requireNonNull(ou));
}
/**
* Sets the city or locality.
*
* Note that it is at the discretion of the ACME server to accept this parameter.
*/
public void setLocality(String l) {
namebuilder.addRDN(BCStyle.L, requireNonNull(l));
}
/**
* Sets the state or province.
*
* Note that it is at the discretion of the ACME server to accept this parameter.
*/
public void setState(String st) {
namebuilder.addRDN(BCStyle.ST, requireNonNull(st));
}
/**
* Sets the country.
*
* Note that it is at the discretion of the ACME server to accept this parameter.
*/
public void setCountry(String c) {
namebuilder.addRDN(BCStyle.C, requireNonNull(c));
}
/**
* Signs the completed CSR.
*
* @param keypair
* {@link KeyPair} to sign the CSR with
*/
public void sign(KeyPair keypair) throws IOException {
Objects.requireNonNull(keypair, "keypair");
if (namelist.isEmpty() && iplist.isEmpty()) {
throw new IllegalStateException("No domain or IP address was set");
}
try {
var ix = 0;
var gns = new GeneralName[namelist.size() + iplist.size()];
for (var name : namelist) {
gns[ix++] = new GeneralName(GeneralName.dNSName, name);
}
for (var ip : iplist) {
gns[ix++] = new GeneralName(GeneralName.iPAddress, ip.getHostAddress());
}
var subjectAltName = new GeneralNames(gns);
var p10Builder = new JcaPKCS10CertificationRequestBuilder(namebuilder.build(), keypair.getPublic());
var extensionsGenerator = new ExtensionsGenerator();
extensionsGenerator.addExtension(Extension.subjectAlternativeName, false, subjectAltName);
p10Builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionsGenerator.generate());
var pk = keypair.getPrivate();
var csBuilder = new JcaContentSignerBuilder(pk instanceof ECKey ? EC_SIGNATURE_ALG : SIGNATURE_ALG);
var signer = csBuilder.build(pk);
csr = p10Builder.build(signer);
} catch (OperatorCreationException ex) {
throw new IOException("Could not generate CSR", ex);
}
}
/**
* Gets the PKCS#10 certification request.
*/
public PKCS10CertificationRequest getCSR() {
if (csr == null) {
throw new IllegalStateException("sign CSR first");
}
return csr;
}
/**
* Gets an encoded PKCS#10 certification request.
*/
public byte[] getEncoded() throws IOException {
return getCSR().getEncoded();
}
/**
* Writes the signed certificate request to a {@link Writer}.
*
* @param w
* {@link Writer} to write the PEM file to. The {@link Writer} is closed
* after use.
*/
public void write(Writer w) throws IOException {
if (csr == null) {
throw new IllegalStateException("sign CSR first");
}
try (var pw = new PemWriter(w)) {
pw.writeObject(new PemObject("CERTIFICATE REQUEST", getEncoded()));
}
}
/**
* Writes the signed certificate request to an {@link OutputStream}.
*
* @param out
* {@link OutputStream} to write the PEM file to. The {@link OutputStream}
* is closed after use.
*/
public void write(OutputStream out) throws IOException {
write(new OutputStreamWriter(out, UTF_8));
}
@Override
public String toString() {
var sb = new StringBuilder();
sb.append(namebuilder.build());
if (!namelist.isEmpty()) {
sb.append(namelist.stream().collect(joining(",DNS=", ",DNS=", "")));
}
if (!iplist.isEmpty()) {
sb.append(iplist.stream()
.map(InetAddress::getHostAddress)
.collect(joining(",IP=", ",IP=", "")));
}
return sb.toString();
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Objects;
import java.util.function.Function;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.CertIOException;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509v1CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
/**
* Utility class offering convenience methods for certificates.
*
* Requires {@code Bouncy Castle}.
*/
public final class CertificateUtils {
/**
* The {@code acmeValidation} object identifier.
*
* @since 2.1
*/
public static final ASN1ObjectIdentifier ACME_VALIDATION =
new ASN1ObjectIdentifier(TlsAlpn01Challenge.ACME_VALIDATION_OID).intern();
private CertificateUtils() {
// utility class without constructor
}
/**
* Reads a CSR PEM file.
*
* @param in
* {@link InputStream} to read the CSR from. The {@link InputStream} is
* closed after use.
* @return CSR that was read
*/
public static PKCS10CertificationRequest readCSR(InputStream in) throws IOException {
try (var pemParser = new PEMParser(new InputStreamReader(in, StandardCharsets.US_ASCII))) {
var parsedObj = pemParser.readObject();
if (!(parsedObj instanceof PKCS10CertificationRequest)) {
throw new IOException("Not a PKCS10 CSR");
}
return (PKCS10CertificationRequest) parsedObj;
}
}
/**
* Creates a self-signed {@link X509Certificate} that can be used for the
* {@link TlsAlpn01Challenge}. The certificate is valid for 7 days.
*
* @param keypair
* A domain {@link KeyPair} to be used for the challenge
* @param id
* The {@link Identifier} that is to be validated
* @param acmeValidation
* The value that is returned by
* {@link TlsAlpn01Challenge#getAcmeValidation()}
* @return Created certificate
* @since 2.6
*/
public static X509Certificate createTlsAlpn01Certificate(KeyPair keypair, Identifier id, byte[] acmeValidation)
throws IOException {
Objects.requireNonNull(keypair, "keypair");
Objects.requireNonNull(id, "id");
if (acmeValidation == null || acmeValidation.length != 32) {
throw new IllegalArgumentException("Bad acmeValidation parameter");
}
var now = System.currentTimeMillis();
var issuer = new X500Name("CN=acme.invalid");
var serial = BigInteger.valueOf(now);
var notBefore = Instant.ofEpochMilli(now);
var notAfter = notBefore.plus(Duration.ofDays(7));
var certBuilder = new JcaX509v3CertificateBuilder(
issuer, serial, Date.from(notBefore), Date.from(notAfter),
issuer, keypair.getPublic());
var gns = new GeneralName[1];
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));
return buildCertificate(certBuilder::build, keypair.getPrivate());
}
/**
* Creates a self-signed root certificate.
*
* The generated certificate is only meant for testing purposes!
*
* @param subject
* This certificate's subject X.500 name.
* @param notBefore
* {@link Instant} before which the certificate is not valid.
* @param notAfter
* {@link Instant} after which the certificate is not valid.
* @param keypair
* {@link KeyPair} that is to be used for this certificate.
* @return Generated {@link X509Certificate}
* @since 2.8
*/
public static X509Certificate createTestRootCertificate(String subject,
Instant notBefore, Instant notAfter, KeyPair keypair) {
Objects.requireNonNull(subject, "subject");
Objects.requireNonNull(notBefore, "notBefore");
Objects.requireNonNull(notAfter, "notAfter");
Objects.requireNonNull(keypair, "keypair");
var certBuilder = new JcaX509v1CertificateBuilder(
new X500Name(subject),
BigInteger.valueOf(System.currentTimeMillis()),
Date.from(notBefore),
Date.from(notAfter),
new X500Name(subject),
keypair.getPublic()
);
return buildCertificate(certBuilder::build, keypair.getPrivate());
}
/**
* Creates an intermediate certificate that is signed by an issuer.
*
* The generated certificate is only meant for testing purposes!
*
* @param subject
* This certificate's subject X.500 name.
* @param notBefore
* {@link Instant} before which the certificate is not valid.
* @param notAfter
* {@link Instant} after which the certificate is not valid.
* @param intermediatePublicKey
* {@link PublicKey} of this certificate
* @param issuer
* The issuer's {@link X509Certificate}.
* @param issuerPrivateKey
* {@link PrivateKey} of the issuer. This is not the private key of this
* intermediate certificate.
* @return Generated {@link X509Certificate}
* @since 2.8
*/
public static X509Certificate createTestIntermediateCertificate(String subject,
Instant notBefore, Instant notAfter, PublicKey intermediatePublicKey,
X509Certificate issuer, PrivateKey issuerPrivateKey) {
Objects.requireNonNull(subject, "subject");
Objects.requireNonNull(notBefore, "notBefore");
Objects.requireNonNull(notAfter, "notAfter");
Objects.requireNonNull(intermediatePublicKey, "intermediatePublicKey");
Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(issuerPrivateKey, "issuerPrivateKey");
var certBuilder = new JcaX509v1CertificateBuilder(
new X500Name(issuer.getIssuerX500Principal().getName()),
BigInteger.valueOf(System.currentTimeMillis()),
Date.from(notBefore),
Date.from(notAfter),
new X500Name(subject),
intermediatePublicKey
);
return buildCertificate(certBuilder::build, issuerPrivateKey);
}
/**
* Creates a signed end entity certificate from the given CSR.
*
* This method is only meant for testing purposes! Do not use it in a real-world CA
* implementation.
*
* Do not assume that real-world certificates have a similar structure. It's up to the
* discretion of the CA which distinguished names, validity dates, extensions and
* other parameters are transferred from the CSR to the generated certificate.
*
* @param csr
* CSR to create the certificate from
* @param notBefore
* {@link Instant} before which the certificate is not valid.
* @param notAfter
* {@link Instant} after which the certificate is not valid.
* @param issuer
* The issuer's {@link X509Certificate}.
* @param issuerPrivateKey
* {@link PrivateKey} of the issuer. This is not the private key the CSR was
* signed with.
* @return Generated {@link X509Certificate}
* @since 2.8
*/
public static X509Certificate createTestCertificate(PKCS10CertificationRequest csr,
Instant notBefore, Instant notAfter, X509Certificate issuer, PrivateKey issuerPrivateKey) {
Objects.requireNonNull(csr, "csr");
Objects.requireNonNull(notBefore, "notBefore");
Objects.requireNonNull(notAfter, "notAfter");
Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(issuerPrivateKey, "issuerPrivateKey");
try {
var jcaCsr = new JcaPKCS10CertificationRequest(csr);
var certBuilder = new JcaX509v3CertificateBuilder(
new X500Name(issuer.getIssuerX500Principal().getName()),
BigInteger.valueOf(System.currentTimeMillis()),
Date.from(notBefore),
Date.from(notAfter),
csr.getSubject(),
jcaCsr.getPublicKey());
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 extension0) {
var san = GeneralNames.fromExtensions(extension0, Extension.subjectAlternativeName);
var critical = csr.getSubject().getRDNs().length == 0;
certBuilder.addExtension(Extension.subjectAlternativeName, critical, san);
}
}
return buildCertificate(certBuilder::build, issuerPrivateKey);
} catch (NoSuchAlgorithmException | InvalidKeyException | CertIOException ex) {
throw new IllegalArgumentException("Invalid CSR", ex);
}
}
/**
* Build a {@link X509Certificate} from a builder.
*
* @param builder
* Builder method that receives a {@link ContentSigner} and returns a {@link
* X509CertificateHolder}.
* @param privateKey
* {@link PrivateKey} to sign the certificate with
* @return The generated {@link X509Certificate}
*/
private static X509Certificate buildCertificate(Function builder, PrivateKey privateKey) {
try {
var signerBuilder = new JcaContentSignerBuilder("SHA256withRSA");
var cert = builder.apply(signerBuilder.build(privateKey)).getEncoded();
var certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(cert));
} catch (CertificateException | OperatorCreationException | IOException ex) {
throw new IllegalArgumentException("Could not build certificate", ex);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.PublicKey;
import java.security.SecureRandom;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMException;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
/**
* Utility class offering convenience methods for {@link KeyPair}.
*
* Requires {@code Bouncy Castle}.
*/
public class KeyPairUtils {
private KeyPairUtils() {
// utility class without constructor
}
/**
* Creates a new standard {@link KeyPair}.
*
* This method can be used if no specific key type is required. It returns a
* "secp384r1" ECDSA key pair.
*
* @return Generated {@link KeyPair}
* @since 2.8
*/
public static KeyPair createKeyPair() {
return createECKeyPair("secp384r1");
}
/**
* Creates a new RSA {@link KeyPair}.
*
* @param keysize
* Key size
* @return Generated {@link KeyPair}
*/
public static KeyPair createKeyPair(int keysize) {
try {
var keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(keysize);
return keyGen.generateKeyPair();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Creates a new elliptic curve {@link KeyPair}.
*
* @param name
* ECDSA curve name (e.g. "secp256r1")
* @return Generated {@link KeyPair}
*/
public static KeyPair createECKeyPair(String name) {
try {
var ecSpec = ECNamedCurveTable.getParameterSpec(name);
var g = KeyPairGenerator.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
g.initialize(ecSpec, new SecureRandom());
return g.generateKeyPair();
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException ex) {
throw new IllegalArgumentException("Invalid curve name " + name, ex);
} catch (NoSuchProviderException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Reads a {@link KeyPair} from a PEM file.
*
* @param r
* {@link Reader} to read the PEM file from. The {@link Reader} is closed
* after use.
* @return {@link KeyPair} read
*/
public static KeyPair readKeyPair(Reader r) throws IOException {
try (var parser = new PEMParser(r)) {
var keyPair = (PEMKeyPair) parser.readObject();
return new JcaPEMKeyConverter().getKeyPair(keyPair);
} catch (PEMException ex) {
throw new IOException("Invalid PEM file", ex);
}
}
/**
* Writes a {@link KeyPair} PEM file.
*
* @param keypair
* {@link KeyPair} to write
* @param w
* {@link Writer} to write the PEM file to. The {@link Writer} is closed
* after use.
*/
public static void writeKeyPair(KeyPair keypair, Writer w) throws IOException {
try (var jw = new JcaPEMWriter(w)) {
jw.writeObject(keypair);
}
}
/**
* Writes a {@link PublicKey} as PEM file.
*
* @param key
* {@link PublicKey}
* @param w
* {@link Writer} to write the PEM file to. The {@link Writer} is closed
* after use.
* @since 3.0.0
*/
public static void writePublicKey(PublicKey key, Writer w) throws IOException {
try (var jw = new JcaPEMWriter(w)) {
jw.writeObject(key);
}
}
}
================================================
FILE: acme4j-client/src/main/java/org/shredzone/acme4j/util/package-info.java
================================================
/*
* 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.
*/
/**
* A collection of utility classes. All of them require Bouncy Castle to be added as
* * security provider.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.util;
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;
================================================
FILE: acme4j-client/src/main/resources/.gitignore
================================================
================================================
FILE: acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider
================================================
# Actalis: https://www.actalis.com/
org.shredzone.acme4j.provider.actalis.ActalisAcmeProvider
# 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
================================================
FILE: acme4j-client/src/main/resources/org/shredzone/acme4j/provider/pebble/pebble.minica.pem
================================================
-----BEGIN CERTIFICATE-----
MIIDPzCCAiegAwIBAgIIU0Xm9UFdQxUwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgNTM0NWU2MCAXDTI1MDkwMzIzNDAwNVoYDzIxMjUw
OTAzMjM0MDA1WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA1MzQ1ZTYwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
AAGjezB5MA4GA1UdDwEB/wQEAwIChDATBgNVHSUEDDAKBggrBgEFBQcDATASBgNV
HRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSu8RGpErgYUoYnQuwCq+/ggTiEjDAf
BgNVHSMEGDAWgBSu8RGpErgYUoYnQuwCq+/ggTiEjDANBgkqhkiG9w0BAQsFAAOC
AQEAXDVYov1+f6EL7S41LhYQkEX/GyNNzsEvqxE9U0+3Iri5JfkcNOiA9O9L6Z+Y
bqcsXV93s3vi4r4WSWuc//wHyJYrVe5+tK4nlFpbJOvfBUtnoBDyKNxXzZCxFJVh
f9uc8UejRfQMFbDbhWY/x83y9BDufJHHq32OjCIN7gp2UR8rnfYvlz7Zg4qkJBsn
DG4dwd+pRTCFWJOVIG0JoNhK3ZmE7oJ1N4H38XkZ31NPcMksKxpsLLIS9+mosZtg
4olL7tMPJklx5ZaeMFaKRDq4Gdxkbw4+O4vRgNm3Z8AXWKknOdfgdpqLUPPhRcP4
v1lhy71EhBuXXwRQJry0lTdF+w==
-----END CERTIFICATE-----
================================================
FILE: acme4j-client/src/main/resources-filtered/org/shredzone/acme4j/version.properties
================================================
version=${project.version}
================================================
FILE: acme4j-client/src/test/java/.gitignore
================================================
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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 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.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.RequestSigner;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtilsTest;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link AccountBuilder}.
*/
public class AccountBuilderTest {
private final URL resourceUrl = url("http://example.com/acme/resource");
private final URL locationUrl = url("http://example.com/acme/account");
/**
* Test if a new account can be created.
*/
@Test
public void testRegistration() throws Exception {
var accountKey = TestUtils.createKeyPair();
var provider = new TestableConnectionProvider() {
private boolean isUpdate;
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(login).isNotNull();
assertThat(url).isEqualTo(locationUrl);
assertThat(isUpdate).isFalse();
isUpdate = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer) {
assertThat(session).isNotNull();
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("newAccount").toString());
isUpdate = false;
return HttpURLConnection.HTTP_CREATED;
}
@Override
public URL getLocation() {
return locationUrl;
}
@Override
public JSON readJsonResponse() {
return getJSON("newAccountResponse");
}
};
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);
var builder = new AccountBuilder();
builder.addContact("mailto:foo@example.com");
builder.agreeToTermsOfService();
builder.useKeyPair(accountKey);
var session = provider.createSession();
var login = builder.createLogin(session);
var account = login.getAccount();
assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
assertThat(account.getLocation()).isEqualTo(locationUrl);
assertThat(account.hasExternalAccountBinding()).isFalse();
assertThat(account.getKeyIdentifier()).isEmpty();
provider.close();
}
/**
* Test if a new account with Key Identifier can be created.
*/
@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(keyAlg);
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer) {
assertThat(session).isNotNull();
assertThat(url).isEqualTo(resourceUrl);
var binding = claims.toJSON()
.get("externalAccountBinding")
.asObject();
var encodedHeader = binding.get("protected").asString();
var encodedSignature = binding.get("signature").asString();
var encodedPayload = binding.get("payload").asString();
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, expectedMacAlg);
return HttpURLConnection.HTTP_CREATED;
}
@Override
public URL getLocation() {
return locationUrl;
}
@Override
public JSON readJsonResponse() {
return JSON.empty();
}
@Override
public Optional 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);
assertThat(login.getAccount().getLocation()).isEqualTo(locationUrl);
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.
*/
@Test
public void testOnlyExistingRegistration() throws Exception {
var accountKey = TestUtils.createKeyPair();
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer) {
assertThat(session).isNotNull();
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("newAccountOnlyExisting").toString());
return HttpURLConnection.HTTP_OK;
}
@Override
public URL getLocation() {
return locationUrl;
}
@Override
public JSON readJsonResponse() {
return getJSON("newAccountResponse");
}
};
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);
var builder = new AccountBuilder();
builder.useKeyPair(accountKey);
builder.onlyExisting();
var session = provider.createSession();
var login = builder.createLogin(session);
assertThat(login.getAccount().getLocation()).isEqualTo(locationUrl);
provider.close();
}
@Test
public void testEmailAddresses() {
var builder = Mockito.spy(AccountBuilder.class);
builder.addEmail("foo@example.com");
Mockito.verify(builder).addContact(Mockito.eq("mailto:foo@example.com"));
// mailto is still accepted if present
builder.addEmail("mailto:bar@example.com");
Mockito.verify(builder).addContact(Mockito.eq("mailto:bar@example.com"));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
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.URI;
import java.net.URL;
import java.util.Collection;
import java.util.concurrent.atomic.AtomicBoolean;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwx.CompactSerializer;
import org.jose4j.lang.JoseException;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Account}.
*/
public class AccountTest {
private final URL resourceUrl = url("http://example.com/acme/resource");
private final URL locationUrl = url(TestUtils.ACCOUNT_URL);
private final URL agreementUrl = url("http://example.com/agreement.pdf");
/**
* Test that a account can be updated.
*/
@Test
public void testUpdateAccount() throws AcmeException, IOException {
var provider = new TestableConnectionProvider() {
private JSON jsonResponse;
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("updateAccount").toString());
assertThat(login).isNotNull();
jsonResponse = getJSON("updateAccountResponse");
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
if ("https://example.com/acme/acct/1/orders".equals(url.toExternalForm())) {
jsonResponse = new JSONBuilder()
.array("orders", singletonList("https://example.com/acme/order/1"))
.toJSON();
} else {
jsonResponse = getJSON("updateAccountResponse");
}
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return jsonResponse;
}
@Override
public URL getLocation() {
return locationUrl;
}
@Override
public Collection getLinks(String relation) {
return emptyList();
}
};
var login = provider.createLogin();
var account = new Account(login, locationUrl);
account.fetch();
assertThat(login.getAccount().getLocation()).isEqualTo(locationUrl);
assertThat(account.getLocation()).isEqualTo(locationUrl);
assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
assertThat(account.getContacts()).hasSize(1);
assertThat(account.getContacts().get(0)).isEqualTo(URI.create("mailto:foo2@example.com"));
assertThat(account.getStatus()).isEqualTo(Status.VALID);
assertThat(account.hasExternalAccountBinding()).isTrue();
assertThat(account.getKeyIdentifier().orElseThrow()).isEqualTo("NCC-1701");
var orderIt = account.getOrders();
assertThat(orderIt).isNotNull();
assertThat(orderIt.next().getLocation()).isEqualTo(url("https://example.com/acme/order/1"));
assertThat(orderIt.hasNext()).isFalse();
provider.close();
}
/**
* Test lazy loading.
*/
@Test
public void testLazyLoading() throws IOException {
var requestWasSent = new AtomicBoolean(false);
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
requestWasSent.set(true);
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAccountResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
@Override
public Collection getLinks(String relation) {
return switch (relation) {
case "termsOfService" -> singletonList(agreementUrl);
default -> emptyList();
};
}
};
var account = new Account(provider.createLogin(), locationUrl);
// Lazy loading
assertThat(requestWasSent.get()).isFalse();
assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
assertThat(requestWasSent.get()).isTrue();
// Subsequent queries do not trigger another load
requestWasSent.set(false);
assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
assertThat(account.getStatus()).isEqualTo(Status.VALID);
assertThat(requestWasSent.get()).isFalse();
provider.close();
}
/**
* Test that a domain can be pre-authorized.
*/
@Test
public void testPreAuthorizeDomain() 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("newAuthorizationRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("newAuthorizationResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
provider.putTestChallenge(Http01Challenge.TYPE, Http01Challenge::new);
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
var domainName = "example.org";
var account = new Account(login, locationUrl);
var auth = account.preAuthorize(Identifier.dns(domainName));
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
assertThat(auth.getExpires()).isEmpty();
assertThat(auth.getLocation()).isEqualTo(locationUrl);
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
provider.getChallenge(Http01Challenge.TYPE),
provider.getChallenge(Dns01Challenge.TYPE));
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, locationUrl);
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, locationUrl);
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.
*/
@Test
public void testNoPreAuthorizeDomain() throws Exception {
var problemType = URI.create("urn:ietf:params:acme:error:rejectedIdentifier");
var problemDetail = "example.org is blacklisted";
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequest").toString());
assertThat(login).isNotNull();
var problem = TestUtils.createProblem(problemType, problemDetail, resourceUrl);
throw new AcmeServerException(problem);
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
var account = new Account(login, locationUrl);
var ex = assertThrows(AcmeServerException.class, () ->
account.preAuthorizeDomain("example.org")
);
assertThat(ex.getType()).isEqualTo(problemType);
assertThat(ex.getMessage()).isEqualTo(problemDetail);
provider.close();
}
/**
* Test that a bad domain parameter is not accepted.
*/
@Test
public void testAuthorizeBadDomain() throws Exception {
var provider = new TestableConnectionProvider();
// just provide a resource record so the provider returns a directory
provider.putTestResource(Resource.NEW_NONCE, resourceUrl);
var login = provider.createLogin();
var account = login.getAccount();
assertThatNullPointerException()
.isThrownBy(() -> account.preAuthorizeDomain(null));
assertThatIllegalArgumentException()
.isThrownBy(() -> account.preAuthorizeDomain(""));
assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(() -> account.preAuthorizeDomain("example.com"))
.withMessage("Server does not support newAuthz");
provider.close();
}
/**
* Test that the account key can be changed.
*/
@Test
public void testChangeKey() throws Exception {
var oldKeyPair = TestUtils.createKeyPair();
var newKeyPair = TestUtils.createDomainKeyPair();
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder payload, Login login) {
try {
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
assertThat(login.getPublicKey()).isSameAs(oldKeyPair.getPublic());
var json = payload.toJSON();
var encodedHeader = json.get("protected").asString();
var encodedSignature = json.get("signature").asString();
var encodedPayload = json.get("payload").asString();
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
var jws = new JsonWebSignature();
jws.setCompactSerialization(serialized);
jws.setKey(newKeyPair.getPublic());
assertThat(jws.verifySignature()).isTrue();
var decodedPayload = jws.getPayload();
var expectedPayload = new StringBuilder();
expectedPayload.append('{');
expectedPayload.append("\"account\":\"").append(locationUrl).append("\",");
expectedPayload.append("\"oldKey\":{");
expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\",");
expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\",");
expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\"");
expectedPayload.append("}}");
assertThatJson(decodedPayload).isEqualTo(expectedPayload.toString());
} catch (JoseException ex) {
fail(ex);
}
return HttpURLConnection.HTTP_OK;
}
@Override
public URL getLocation() {
return locationUrl;
}
};
provider.putTestResource(Resource.KEY_CHANGE, locationUrl);
var session = TestUtils.session(provider);
var login = new Login(locationUrl, oldKeyPair, session);
assertThat(login.getPublicKey()).isSameAs(oldKeyPair.getPublic());
var account = new Account(login, locationUrl);
account.changeKey(newKeyPair);
assertThat(login.getPublicKey()).isSameAs(newKeyPair.getPublic());
}
/**
* Test that the same account key is not accepted for change.
*/
@Test
public void testChangeSameKey() {
assertThrows(IllegalArgumentException.class, () -> {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
var account = new Account(login, locationUrl);
account.changeKey(provider.getAccountKeyPair());
provider.close();
});
}
/**
* Test that an account can be deactivated.
*/
@Test
public void testDeactivate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
var json = claims.toJSON();
assertThat(json.get("status").asString()).isEqualTo("deactivated");
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("deactivateAccountResponse");
}
};
var account = new Account(provider.createLogin(), locationUrl);
account.deactivate();
assertThat(account.getStatus()).isEqualTo(Status.DEACTIVATED);
provider.close();
}
/**
* Test that a new order can be created.
*/
@Test
public void testNewOrder() throws AcmeException, IOException {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
var account = new Account(login, locationUrl);
assertThat(account.newOrder()).isNotNull();
provider.close();
}
/**
* Test that an account can be modified.
*/
@Test
public void testModify() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("modifyAccount").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("modifyAccountResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var account = new Account(provider.createLogin(), locationUrl);
account.setJSON(getJSON("newAccount"));
var editable = account.modify();
assertThat(editable).isNotNull();
editable.addContact("mailto:foo2@example.com");
editable.getContacts().add(URI.create("mailto:foo3@example.com"));
editable.commit();
assertThat(account.getLocation()).isEqualTo(locationUrl);
assertThat(account.getContacts()).hasSize(3);
assertThat(account.getContacts()).element(0).isEqualTo(URI.create("mailto:foo@example.com"));
assertThat(account.getContacts()).element(1).isEqualTo(URI.create("mailto:foo2@example.com"));
assertThat(account.getContacts()).element(2).isEqualTo(URI.create("mailto:foo3@example.com"));
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.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;
/**
* Unit tests for {@link AcmeJsonResource}.
*/
public class AcmeJsonResourceTest {
private static final JSON JSON_DATA = getJSON("newAccountResponse");
private static final URL LOCATION_URL = url("https://example.com/acme/resource/123");
/**
* Test {@link AcmeJsonResource#AcmeJsonResource(Login, URL)}.
*/
@Test
public void testLoginConstructor() {
var login = TestUtils.login();
var resource = new DummyJsonResource(login, LOCATION_URL);
assertThat(resource.getLogin()).isEqualTo(login);
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);
assertThat(resource.isValid()).isTrue();
assertUpdateInvoked(resource, 1);
}
/**
* Test {@link AcmeJsonResource#setJSON(JSON)}.
*/
@Test
public void testSetJson() {
var login = TestUtils.login();
var jsonData2 = getJSON("requestOrderResponse");
var resource = new DummyJsonResource(login, LOCATION_URL);
assertThat(resource.isValid()).isFalse();
assertUpdateInvoked(resource, 0);
resource.setJSON(JSON_DATA);
assertThat(resource.getJSON()).isEqualTo(JSON_DATA);
assertThat(resource.isValid()).isTrue();
assertUpdateInvoked(resource, 0);
resource.setJSON(jsonData2);
assertThat(resource.getJSON()).isEqualTo(jsonData2);
assertThat(resource.isValid()).isTrue();
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()}.
*/
@Test
public void testInvalidate() {
var login = TestUtils.login();
var resource = new DummyJsonResource(login, LOCATION_URL);
assertThat(resource.isValid()).isFalse();
assertUpdateInvoked(resource, 0);
resource.setJSON(JSON_DATA);
assertThat(resource.isValid()).isTrue();
assertUpdateInvoked(resource, 0);
resource.invalidate();
assertThat(resource.isValid()).isFalse();
assertUpdateInvoked(resource, 0);
assertThat(resource.getJSON()).isEqualTo(JSON_DATA);
assertThat(resource.isValid()).isTrue();
assertUpdateInvoked(resource, 1);
}
/**
* Assert that {@link AcmeJsonResource#update()} has been invoked a given number of
* times.
*
* @param resource
* {@link AcmeJsonResource} to test
* @param count
* Expected number of times
*/
private static void assertUpdateInvoked(AcmeJsonResource resource, int count) {
var dummy = (DummyJsonResource) resource;
assertThat(dummy.updateCount).as("update counter").isEqualTo(count);
}
/**
* Minimum implementation of {@link AcmeJsonResource}.
*/
private static class DummyJsonResource extends AcmeJsonResource {
@Serial
private static final long serialVersionUID = -6459238185161771948L;
private int updateCount = 0;
public DummyJsonResource(Login login, URL location) {
super(login, location);
}
public DummyJsonResource(Login login, URL location, JSON json, @Nullable Instant retryAfter) {
super(login, location);
setJSON(json);
setRetryAfter(retryAfter);
}
@Override
public Optional 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();
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/AcmeResourceTest.java
================================================
/*
* 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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
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;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link AcmeResource}.
*/
public class AcmeResourceTest {
/**
* Test constructors and setters
*/
@Test
public void testConstructor() throws Exception {
var login = TestUtils.login();
var location = URI.create("http://example.com/acme/resource").toURL();
assertThrows(NullPointerException.class, () -> new DummyResource(null, null));
var resource = new DummyResource(login, location);
assertThat(resource.getLogin()).isEqualTo(login);
assertThat(resource.getLocation()).isEqualTo(location);
}
/**
* Test if {@link AcmeResource} is properly serialized.
*/
@Test
public void testSerialization() throws Exception {
var login = TestUtils.login();
var location = URI.create("http://example.com/acme/resource").toURL();
// Create a Challenge for testing
var challenge = new DummyResource(login, location);
assertThat(challenge.getLogin()).isEqualTo(login);
// Serialize it
byte[] serialized;
try (var baos = new ByteArrayOutputStream()) {
try (var out = new ObjectOutputStream(baos)) {
out.writeObject(challenge);
}
serialized = baos.toByteArray();
}
// Make sure there is no PrivateKey in the stream
var str = new String(serialized, StandardCharsets.ISO_8859_1);
assertThat(str).as("serialized stream contains a PrivateKey")
.doesNotContain("Ljava/security/PrivateKey");
// Deserialize to new object
DummyResource restored;
try (var bais = new ByteArrayInputStream(serialized);
var in = new ObjectInputStream(bais)) {
var obj = in.readObject();
assertThat(obj).isInstanceOf(DummyResource.class);
restored = (DummyResource) obj;
}
assertThat(restored).isNotSameAs(challenge);
// Make sure the restored object is not attached to a login
assertThrows(IllegalStateException.class, restored::getLogin);
// Rebind to login
restored.rebind(login);
// Make sure the new login is set
assertThat(restored.getLogin()).isEqualTo(login);
}
/**
* Test if a rebind attempt fails.
*/
@Test
public void testRebind() {
assertThrows(IllegalStateException.class, () -> {
var login = TestUtils.login();
var location = URI.create("http://example.com/acme/resource").toURL();
var resource = new DummyResource(login, location);
assertThat(resource.getLogin()).isEqualTo(login);
var login2 = TestUtils.login();
resource.rebind(login2); // fails to rebind to another login
});
}
/**
* 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);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.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;
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.AcmeProtocolException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Unit tests for {@link Authorization}.
*/
public class AuthorizationTest {
private static final String SNAILMAIL_TYPE = "snail-01"; // a non-existent challenge
private static final String DUPLICATE_TYPE = "duplicate-01"; // a duplicate challenge
private final URL locationUrl = url("http://example.com/acme/account");
/**
* Test that {@link Authorization#findChallenge(String)} finds challenges.
*/
@Test
public void testFindChallenge() throws IOException {
var authorization = createChallengeAuthorization();
// A snail mail challenge is not available at all
var c1 = authorization.findChallenge(SNAILMAIL_TYPE);
assertThat(c1).isEmpty();
// HttpChallenge is available
var c2 = authorization.findChallenge(Http01Challenge.TYPE);
assertThat(c2).isNotEmpty();
assertThat(c2.get()).isInstanceOf(Http01Challenge.class);
// Dns01Challenge is available
var c3 = authorization.findChallenge(Dns01Challenge.TYPE);
assertThat(c3).isNotEmpty();
assertThat(c3.get()).isInstanceOf(Dns01Challenge.class);
// TlsAlpn01Challenge is available
var c4 = authorization.findChallenge(TlsAlpn01Challenge.TYPE);
assertThat(c4).isNotEmpty();
assertThat(c4.get()).isInstanceOf(TlsAlpn01Challenge.class);
}
/**
* Test that {@link Authorization#findChallenge(Class)} finds challenges.
*/
@Test
public void testFindChallengeByType() throws IOException {
var authorization = createChallengeAuthorization();
// A snail mail challenge is not available at all
var c1 = authorization.findChallenge(NonExistingChallenge.class);
assertThat(c1).isEmpty();
// HttpChallenge is available
var c2 = authorization.findChallenge(Http01Challenge.class);
assertThat(c2).isNotEmpty();
// Dns01Challenge is available
var c3 = authorization.findChallenge(Dns01Challenge.class);
assertThat(c3).isNotEmpty();
// TlsAlpn01Challenge is available
var c4 = authorization.findChallenge(TlsAlpn01Challenge.class);
assertThat(c4).isNotEmpty();
}
/**
* Test that {@link Authorization#findChallenge(String)} fails on duplicate
* challenges.
*/
@Test
public void testFailDuplicateChallenges() {
assertThrows(AcmeProtocolException.class, () -> {
var authorization = createChallengeAuthorization();
authorization.findChallenge(DUPLICATE_TYPE);
});
}
/**
* Test that authorization is properly updated.
*/
@Test
public void testUpdate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
};
var login = provider.createLogin();
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
auth.fetch();
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
assertThat(auth.isWildcard()).isFalse();
assertThat(auth.getExpires().orElseThrow()).isCloseTo("2016-01-02T17:12:40Z", within(1, ChronoUnit.SECONDS));
assertThat(auth.getLocation()).isEqualTo(locationUrl);
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
provider.getChallenge(Http01Challenge.TYPE),
provider.getChallenge(Dns01Challenge.TYPE),
provider.getChallenge(TlsAlpn01Challenge.TYPE));
provider.close();
}
/**
* Test that wildcard authorization are correct.
*/
@Test
public void testWildcard() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAuthorizationWildcardResponse");
}
};
var login = provider.createLogin();
provider.putTestChallenge("dns-01", Dns01Challenge::new);
var auth = new Authorization(login, locationUrl);
auth.fetch();
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
assertThat(auth.isWildcard()).isTrue();
assertThat(auth.getExpires().orElseThrow()).isCloseTo("2016-01-02T17:12:40Z", within(1, ChronoUnit.SECONDS));
assertThat(auth.getLocation()).isEqualTo(locationUrl);
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
provider.getChallenge(Dns01Challenge.TYPE));
provider.close();
}
/**
* Test lazy loading.
*/
@Test
public void testLazyLoading() throws Exception {
var requestWasSent = new AtomicBoolean(false);
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
requestWasSent.set(true);
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
};
var login = provider.createLogin();
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
// Lazy loading
assertThat(requestWasSent).isFalse();
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(requestWasSent).isTrue();
// Subsequent queries do not trigger another load
requestWasSent.set(false);
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
assertThat(auth.isWildcard()).isFalse();
assertThat(auth.getExpires().orElseThrow()).isCloseTo("2016-01-02T17:12:40Z", within(1, ChronoUnit.SECONDS));
assertThat(requestWasSent).isFalse();
provider.close();
}
/**
* Test that authorization is properly updated, with retry-after header set.
*/
@Test
public void testUpdateRetryAfter() throws Exception {
var retryAfter = Instant.now().plus(Duration.ofSeconds(30));
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
@Override
public Optional getRetryAfter() {
return Optional.of(retryAfter);
}
};
var login = provider.createLogin();
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
var returnedRetryAfter = auth.fetch();
assertThat(returnedRetryAfter).hasValue(retryAfter);
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
assertThat(auth.isWildcard()).isFalse();
assertThat(auth.getExpires().orElseThrow()).isCloseTo("2016-01-02T17:12:40Z", within(1, ChronoUnit.SECONDS));
assertThat(auth.getLocation()).isEqualTo(locationUrl);
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
provider.getChallenge(Http01Challenge.TYPE),
provider.getChallenge(Dns01Challenge.TYPE),
provider.getChallenge(TlsAlpn01Challenge.TYPE));
provider.close();
}
/**
* Test that an authorization can be deactivated.
*/
@Test
public void testDeactivate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
var json = claims.toJSON();
assertThat(json.get("status").asString()).isEqualTo("deactivated");
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
};
var login = provider.createLogin();
provider.putTestChallenge("http-01", Http01Challenge::new);
provider.putTestChallenge("dns-01", Dns01Challenge::new);
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
auth.deactivate();
provider.close();
}
/**
* Creates an {@link Authorization} instance with a set of challenges.
*/
private Authorization createChallengeAuthorization() throws IOException {
try (var provider = new TestableConnectionProvider()) {
var login = provider.createLogin();
provider.putTestChallenge(Http01Challenge.TYPE, Http01Challenge::new);
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
provider.putTestChallenge(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);
provider.putTestChallenge(DUPLICATE_TYPE, Challenge::new);
var authorization = new Authorization(login, locationUrl);
authorization.setJSON(getJSON("authorizationChallenges"));
return authorization;
}
}
/**
* Dummy challenge that is never going to be created.
*/
private static class NonExistingChallenge extends Challenge {
public NonExistingChallenge(Login login, JSON data) {
super(login, data);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java
================================================
/*
* 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;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.shredzone.acme4j.toolbox.TestUtils.*;
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.cert.X509Certificate;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.connector.RequestSigner;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Certificate}.
*/
public class CertificateTest {
private final URL resourceUrl = url("http://example.com/acme/resource");
private final URL locationUrl = url("http://example.com/acme/certificate");
private final URL alternate1Url = url("https://example.com/acme/alt-cert/1");
private final URL alternate2Url = url("https://example.com/acme/alt-cert/2");
/**
* Test that a certificate can be downloaded.
*/
@Test
public void testDownload() throws Exception {
var originalCert = TestUtils.createCertificate("/cert.pem");
var alternateCert = TestUtils.createCertificate("/certid-cert.pem");
var provider = new TestableConnectionProvider() {
List sendCert;
@Override
public int sendCertificateRequest(URL url, Login login) {
assertThat(url).isIn(locationUrl, alternate1Url, alternate2Url);
assertThat(login).isNotNull();
if (locationUrl.equals(url)) {
sendCert = originalCert;
} else {
sendCert = alternateCert;
}
return HttpURLConnection.HTTP_OK;
}
@Override
public List readCertificates() {
return sendCert;
}
@Override
public Collection getLinks(String relation) {
assertThat(relation).isEqualTo("alternate");
return Arrays.asList(alternate1Url, alternate2Url);
}
};
var cert = new Certificate(provider.createLogin(), locationUrl);
cert.download();
var downloadedCert = cert.getCertificate();
assertThat(downloadedCert.getEncoded()).isEqualTo(originalCert.get(0).getEncoded());
var downloadedChain = cert.getCertificateChain();
assertThat(downloadedChain).hasSize(originalCert.size());
for (var ix = 0; ix < downloadedChain.size(); ix++) {
assertThat(downloadedChain.get(ix).getEncoded()).isEqualTo(originalCert.get(ix).getEncoded());
}
byte[] writtenPem;
byte[] originalPem;
try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) {
cert.writeCertificate(w);
w.flush();
writtenPem = baos.toByteArray();
}
try (var baos = new ByteArrayOutputStream(); var in = getClass().getResourceAsStream("/cert.pem")) {
int len;
var buffer = new byte[2048];
while((len = in.read(buffer)) >= 0) {
baos.write(buffer, 0, len);
}
originalPem = baos.toByteArray();
}
assertThat(writtenPem).isEqualTo(originalPem);
assertThat(cert.isIssuedBy("The ACME CA X1")).isFalse();
assertThat(cert.isIssuedBy(CERT_ISSUER)).isTrue();
assertThat(cert.getAlternates()).isNotNull();
assertThat(cert.getAlternates()).hasSize(2);
assertThat(cert.getAlternates()).element(0).isEqualTo(alternate1Url);
assertThat(cert.getAlternates()).element(1).isEqualTo(alternate2Url);
assertThat(cert.getAlternateCertificates()).isNotNull();
assertThat(cert.getAlternateCertificates()).hasSize(2);
assertThat(cert.getAlternateCertificates())
.element(0)
.extracting(Certificate::getLocation)
.isEqualTo(alternate1Url);
assertThat(cert.getAlternateCertificates())
.element(1)
.extracting(Certificate::getLocation)
.isEqualTo(alternate2Url);
assertThat(cert.findCertificate("The ACME CA X1")).
isEmpty();
assertThat(cert.findCertificate(CERT_ISSUER).orElseThrow())
.isSameAs(cert);
assertThat(cert.findCertificate("minica root ca 3a1356").orElseThrow())
.isSameAs(cert.getAlternateCertificates().get(0));
assertThat(cert.getAlternateCertificates().get(0).isIssuedBy("minica root ca 3a1356"))
.isTrue();
provider.close();
}
/**
* Test that a certificate can be revoked.
*/
@Test
public void testRevokeCertificate() throws AcmeException, IOException {
var originalCert = TestUtils.createCertificate("/cert.pem");
var provider = new TestableConnectionProvider() {
private boolean certRequested = false;
@Override
public int sendCertificateRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
certRequested = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateRequest").toString());
assertThat(login).isNotNull();
certRequested = false;
return HttpURLConnection.HTTP_OK;
}
@Override
public List readCertificates() {
assertThat(certRequested).isTrue();
return originalCert;
}
@Override
public Collection getLinks(String relation) {
assertThat(relation).isEqualTo("alternate");
return Collections.emptyList();
}
};
provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
cert.revoke();
provider.close();
}
/**
* Test that a certificate can be revoked with reason.
*/
@Test
public void testRevokeCertificateWithReason() throws AcmeException, IOException {
var originalCert = TestUtils.createCertificate("/cert.pem");
var provider = new TestableConnectionProvider() {
private boolean certRequested = false;
@Override
public int sendCertificateRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
certRequested = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString());
assertThat(login).isNotNull();
certRequested = false;
return HttpURLConnection.HTTP_OK;
}
@Override
public List readCertificates() {
assertThat(certRequested).isTrue();
return originalCert;
}
@Override
public Collection getLinks(String relation) {
assertThat(relation).isEqualTo("alternate");
return Collections.emptyList();
}
};
provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
cert.revoke(RevocationReason.KEY_COMPROMISE);
provider.close();
}
/**
* Test that numeric revocation reasons are correctly translated.
*/
@Test
public void testRevocationReason() {
assertThat(RevocationReason.code(1))
.isEqualTo(RevocationReason.KEY_COMPROMISE);
}
/**
* Test that a certificate can be revoked by its domain key pair.
*/
@Test
public void testRevokeCertificateByKeyPair() throws AcmeException, IOException {
var originalCert = TestUtils.createCertificate("/cert.pem");
var certKeyPair = TestUtils.createDomainKeyPair();
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString());
assertThat(session).isNotNull();
return HttpURLConnection.HTTP_OK;
}
};
provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
var session = provider.createSession();
Certificate.revoke(session, certKeyPair, originalCert.get(0), RevocationReason.KEY_COMPROMISE);
provider.close();
}
/**
* Test that RenewalInfo is returned.
*/
@Test
public void testRenewalInfo() 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 retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS);
var provider = new TestableConnectionProvider() {
private boolean certRequested = false;
private boolean infoRequested = false;
@Override
public int sendCertificateRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
certRequested = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) {
assertThat(url).isEqualTo(certResourceUrl);
assertThat(session).isNotNull();
assertThat(ifModifiedSince).isNull();
infoRequested = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
assertThat(infoRequested).isTrue();
return getJSON("renewalInfo");
}
@Override
public List readCertificates() {
assertThat(certRequested).isTrue();
return certIdCert;
}
@Override
public Collection getLinks(String relation) {
return Collections.emptyList();
}
@Override
public Optional getRetryAfter() {
return Optional.of(retryAfterInstant);
}
};
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation())
.hasValue(certResourceUrl);
var renewalInfo = cert.getRenewalInfo();
assertThat(renewalInfo.getRetryAfter())
.isEmpty();
assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo("2021-01-03T00:00:00Z");
assertThat(renewalInfo.getSuggestedWindowEnd())
.isEqualTo("2021-01-07T00:00:00Z");
assertThat(renewalInfo.getExplanation())
.isNotEmpty()
.contains(url("https://example.com/docs/example-mass-reissuance-event"));
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 readCertificates() {
assertThat(certRequested).isTrue();
return certIdCert;
}
@Override
public Collection getLinks(String relation) {
return Collections.emptyList();
}
};
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl);
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/IdentifierTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.InetAddress;
import java.net.UnknownHostException;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Unit tests for {@link Identifier}.
*/
public class IdentifierTest {
@Test
public void testConstants() {
assertThat(Identifier.TYPE_DNS).isEqualTo("dns");
assertThat(Identifier.TYPE_IP).isEqualTo("ip");
}
@Test
public void testGetters() {
var id1 = new Identifier("foo", "123.456");
assertThat(id1.getType()).isEqualTo("foo");
assertThat(id1.getValue()).isEqualTo("123.456");
assertThat(id1.toString()).isEqualTo("foo=123.456");
var map1 = id1.toMap();
assertThat(map1).hasSize(2);
assertThat(map1.get("type")).isEqualTo("foo");
assertThat(map1.get("value")).isEqualTo("123.456");
var jb = new JSONBuilder();
jb.put("type", "bar");
jb.put("value", "654.321");
var id2 = new Identifier(jb.toJSON());
assertThat(id2.getType()).isEqualTo("bar");
assertThat(id2.getValue()).isEqualTo("654.321");
assertThat(id2.toString()).isEqualTo("bar=654.321");
var map2 = id2.toMap();
assertThat(map2).hasSize(2);
assertThat(map2.get("type")).isEqualTo("bar");
assertThat(map2.get("value")).isEqualTo("654.321");
}
@Test
public void testDns() {
var id1 = Identifier.dns("example.com");
assertThat(id1.getType()).isEqualTo(Identifier.TYPE_DNS);
assertThat(id1.getValue()).isEqualTo("example.com");
assertThat(id1.getDomain()).isEqualTo("example.com");
var id2 = Identifier.dns("ëxämþlë.com");
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_DNS);
assertThat(id2.getValue()).isEqualTo("xn--xml-qla7ae5k.com");
assertThat(id2.getDomain()).isEqualTo("xn--xml-qla7ae5k.com");
}
@Test
public void testNoDns() {
assertThrows(AcmeProtocolException.class, () ->
new Identifier("foo", "example.com").getDomain()
);
}
@Test
public void testIp() throws UnknownHostException {
var id1 = Identifier.ip(InetAddress.getByName("192.0.2.2"));
assertThat(id1.getType()).isEqualTo(Identifier.TYPE_IP);
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.0.2.99");
assertThat(id3.getType()).isEqualTo(Identifier.TYPE_IP);
assertThat(id3.getValue()).isEqualTo("192.0.2.99");
assertThat(id3.getIP().getHostAddress()).isEqualTo("192.0.2.99");
}
@Test
public void testNoIp() {
assertThrows(AcmeProtocolException.class, () ->
new Identifier("foo", "example.com").getIP()
);
}
@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();
var id3 = new Identifier("foo", "555.666");
assertThat(idRef.equals(id3)).isFalse();
var id4 = new Identifier("sna", "123.456");
assertThat(idRef.equals(id4)).isFalse();
assertThat(idRef.equals(new Object())).isFalse();
assertThat(idRef.equals(null)).isFalse();
}
@Test
public void testNull() {
assertThrows(NullPointerException.class, () -> new Identifier(null, "123.456"));
assertThrows(NullPointerException.class, () -> new Identifier("foo", null));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/LoginTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
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.URI;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Login}.
*/
public class LoginTest {
private final URL resourceUrl = url("https://example.com/acme/resource/123");
/**
* Test the constructor.
*/
@Test
public void testConstructor() throws IOException {
var location = url(TestUtils.ACCOUNT_URL);
var keypair = TestUtils.createKeyPair();
var session = TestUtils.session();
var login = new Login(location, keypair, session);
assertThat(login.getAccount().getLocation()).isEqualTo(location);
assertThat(login.getPublicKey()).isEqualTo(keypair.getPublic());
assertThat(login.getSession()).isEqualTo(session);
assertThat(login.getAccount()).isNotNull();
assertThat(login.getAccount().getLogin()).isEqualTo(login);
assertThat(login.getAccount().getLocation()).isEqualTo(location);
assertThat(login.getAccount().getSession()).isEqualTo(session);
}
/**
* Test the simple binders.
*/
@Test
public void testBinder() throws IOException {
var location = url(TestUtils.ACCOUNT_URL);
var keypair = TestUtils.createKeyPair();
var session = TestUtils.session();
var login = new Login(location, keypair, session);
var auth = login.bindAuthorization(resourceUrl);
assertThat(auth).isNotNull();
assertThat(auth.getLogin()).isEqualTo(login);
assertThat(auth.getLocation()).isEqualTo(resourceUrl);
var cert = login.bindCertificate(resourceUrl);
assertThat(cert).isNotNull();
assertThat(cert.getLogin()).isEqualTo(login);
assertThat(cert.getLocation()).isEqualTo(resourceUrl);
var order = login.bindOrder(resourceUrl);
assertThat(order).isNotNull();
assertThat(order.getLogin()).isEqualTo(login);
assertThat(order.getLocation()).isEqualTo(resourceUrl);
}
/**
* Test that the account's keypair can be changed.
*/
@Test
public void testKeyChange() throws IOException {
var location = url(TestUtils.ACCOUNT_URL);
var keypair = TestUtils.createKeyPair();
var session = TestUtils.session();
var login = new Login(location, keypair, session);
assertThat(login.getPublicKey()).isEqualTo(keypair.getPublic());
var keypair2 = TestUtils.createKeyPair();
login.setKeyPair(keypair2);
assertThat(login.getPublicKey()).isEqualTo(keypair2.getPublic());
}
/**
* Test that challenges are correctly created via provider.
*/
@Test
public void testCreateChallenge() throws Exception {
var challengeType = Http01Challenge.TYPE;
var challengeUrl = url("https://example.com/acme/authz/0");
var data = new JSONBuilder()
.put("type", challengeType)
.put("url", challengeUrl)
.toJSON();
var mockChallenge = mock(Http01Challenge.class);
var mockProvider = mock(AcmeProvider.class);
when(mockProvider.createChallenge(
ArgumentMatchers.any(Login.class),
ArgumentMatchers.eq(data)))
.thenReturn(mockChallenge);
var location = url(TestUtils.ACCOUNT_URL);
var keypair = TestUtils.createKeyPair();
var session = TestUtils.session(mockProvider);
var login = new Login(location, keypair, session);
var challenge = login.createChallenge(data);
assertThat(challenge).isInstanceOf(Http01Challenge.class);
assertThat(challenge).isSameAs(mockChallenge);
verify(mockProvider).createChallenge(login, data);
}
/**
* Test that binding to a challenge invokes createChallenge
*/
@Test
public void testBindChallenge() throws Exception {
var locationUrl = URI.create("https://example.com/acme/challenge/1").toURL();
var mockChallenge = mock(Http01Challenge.class);
when(mockChallenge.getType()).thenReturn(Http01Challenge.TYPE);
var httpChallenge = getJSON("httpChallenge");
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return httpChallenge;
}
@Override
public Challenge createChallenge(Login login, JSON json) {
assertThat(json).isEqualTo(httpChallenge);
return mockChallenge;
}
};
var login = provider.createLogin();
var challenge = login.bindChallenge(locationUrl);
assertThat(challenge).isInstanceOf(Http01Challenge.class);
assertThat(challenge).isSameAs(mockChallenge);
var challenge2 = login.bindChallenge(locationUrl, Http01Challenge.class);
assertThat(challenge2).isSameAs(mockChallenge);
var ex = assertThrows(AcmeProtocolException.class,
() -> login.bindChallenge(locationUrl, Dns01Challenge.class));
assertThat(ex.getMessage()).isEqualTo("Challenge type http-01 does not match" +
" requested class class org.shredzone.acme4j.challenge.Dns01Challenge");
}
/**
* Test that a new order can be created.
*/
@Test
public void testNewOrder() throws AcmeException, IOException {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
assertThat(login.newOrder()).isNotNull();
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
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;
import java.time.Duration;
import java.util.Arrays;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link OrderBuilder}.
*/
public class OrderBuilderTest {
private final URL resourceUrl = url("http://example.com/acme/resource");
private final URL locationUrl = url(TestUtils.ACCOUNT_URL);
/**
* Test that a new {@link Order} can be created.
*/
@Test
public void testOrderCertificate() 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("requestOrderRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestOrderResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var account = new Account(login, locationUrl);
var order = account.newOrder()
.domains("example.com", "www.example.com")
.domain("example.org")
.domains(Arrays.asList("m.example.com", "m.example.org"))
.identifier(Identifier.dns("d.example.com"))
.identifiers(Arrays.asList(
Identifier.dns("d2.example.com"),
Identifier.ip(InetAddress.getByName("192.0.2.2"))))
.notBefore(notBefore)
.notAfter(notAfter)
.create();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
Identifier.dns("example.com"),
Identifier.dns("www.example.com"),
Identifier.dns("example.org"),
Identifier.dns("m.example.com"),
Identifier.dns("m.example.org"),
Identifier.dns("d.example.com"),
Identifier.dns("d2.example.com"),
Identifier.ip(InetAddress.getByName("192.0.2.2")));
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.isAutoRenewing()).isFalse();
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalStartDate);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalEndDate);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalLifetime);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.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);
}
provider.close();
}
/**
* Test that a new auto-renewal {@link Order} can be created.
*/
@Test
public void testAutoRenewOrderCertificate() throws Exception {
var autoRenewStart = parseTimestamp("2018-01-01T00:00:00Z");
var autoRenewEnd = parseTimestamp("2019-01-01T00:00:00Z");
var validity = Duration.ofDays(7);
var predate = Duration.ofDays(6);
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("requestAutoRenewOrderRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestAutoRenewOrderResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putMetadata("auto-renewal",JSON.parse(
"{\"allow-certificate-get\": true}"
).toMap());
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var account = new Account(login, locationUrl);
var order = account.newOrder()
.domain("example.org")
.autoRenewal()
.autoRenewalStart(autoRenewStart)
.autoRenewalEnd(autoRenewEnd)
.autoRenewalLifetime(validity)
.autoRenewalLifetimeAdjust(predate)
.autoRenewalEnableGet()
.create();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(Identifier.dns("example.org"));
softly.assertThat(order.getNotBefore()).isEmpty();
softly.assertThat(order.getNotAfter()).isEmpty();
softly.assertThat(order.isAutoRenewing()).isTrue();
softly.assertThat(order.getAutoRenewalStartDate().orElseThrow()).isEqualTo(autoRenewStart);
softly.assertThat(order.getAutoRenewalEndDate()).isEqualTo(autoRenewEnd);
softly.assertThat(order.getAutoRenewalLifetime()).isEqualTo(validity);
softly.assertThat(order.getAutoRenewalLifetimeAdjust().orElseThrow()).isEqualTo(predate);
softly.assertThat(order.isAutoRenewalGetEnabled()).isTrue();
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
}
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, locationUrl);
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, locationUrl);
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.
*/
@Test
public void testAutoRenewOrderCertificateFails() {
assertThrows(AcmeNotSupportedException.class, () -> {
var provider = new TestableConnectionProvider();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var login = provider.createLogin();
var account = new Account(login, locationUrl);
account.newOrder()
.domain("example.org")
.autoRenewal()
.create();
provider.close();
});
}
/**
* Test that auto-renew and notBefore/notAfter cannot be mixed.
*/
@Test
public void testAutoRenewNotMixed() throws Exception {
var someInstant = parseTimestamp("2018-01-01T00:00:00Z");
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
var account = new Account(login, locationUrl);
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().autoRenewal();
ob.notBefore(someInstant);
}, "accepted notBefore");
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().autoRenewal();
ob.notAfter(someInstant);
}, "accepted notAfter");
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.autoRenewal();
}, "accepted autoRenewal");
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.autoRenewalStart(someInstant);
}, "accepted autoRenewalStart");
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.autoRenewalEnd(someInstant);
}, "accepted autoRenewalEnd");
assertThrows(IllegalArgumentException.class, () -> {
OrderBuilder ob = account.newOrder().notBefore(someInstant);
ob.autoRenewalLifetime(Duration.ofDays(7));
}, "accepted autoRenewalLifetime");
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, locationUrl);
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, locationUrl);
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, locationUrl);
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, locationUrl);
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, locationUrl);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
account.newOrder()
.domain("example.org")
.replaces("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE")
.create();
})
.withMessage("Server does not support renewal-information");
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
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.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Order}.
*/
public class OrderTest {
private final URL locationUrl = url("http://example.com/acme/order/1234");
private final URL finalizeUrl = url("https://example.com/acme/acct/1/order/1/finalize");
/**
* Test that order is properly updated.
*/
@Test
public void testUpdate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateOrderResponse");
}
};
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.fetch();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
softly.assertThat(order.getExpires().orElseThrow()).isEqualTo("2015-03-01T14:09:00Z");
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
Identifier.dns("example.com"),
Identifier.dns("www.example.com"));
softly.assertThat(order.getNotBefore().orElseThrow())
.isEqualTo("2016-01-01T00:00:00Z");
softly.assertThat(order.getNotAfter().orElseThrow())
.isEqualTo("2016-01-08T00:00:00Z");
softly.assertThat(order.getCertificate().getLocation())
.isEqualTo(url("https://example.com/acme/cert/1234"));
softly.assertThat(order.getFinalizeLocation()).isEqualTo(finalizeUrl);
softly.assertThat(order.isAutoRenewing()).isFalse();
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalStartDate);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalEndDate);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getAutoRenewalLifetime);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.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())
.isEqualTo(URI.create("urn:ietf:params:acme:error:connection"));
softly.assertThat(order.getError().flatMap(Problem::getDetail).orElseThrow())
.isEqualTo("connection refused");
var auths = order.getAuthorizations();
softly.assertThat(auths).hasSize(2);
softly.assertThat(auths.stream())
.map(Authorization::getLocation)
.containsExactlyInAnyOrder(
url("https://example.com/acme/authz/1234"),
url("https://example.com/acme/authz/2345"));
}
provider.close();
}
/**
* Test lazy loading.
*/
@Test
public void testLazyLoading() throws Exception {
var requestWasSent = new AtomicBoolean(false);
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
requestWasSent.set(true);
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateOrderResponse");
}
};
var login = provider.createLogin();
var order = new Order(login, locationUrl);
try (var softly = new AutoCloseableSoftAssertions()) {
// Lazy loading
softly.assertThat(requestWasSent).isFalse();
softly.assertThat(order.getCertificate().getLocation())
.isEqualTo(url("https://example.com/acme/cert/1234"));
softly.assertThat(requestWasSent).isTrue();
// Subsequent queries do not trigger another load
requestWasSent.set(false);
softly.assertThat(order.getCertificate().getLocation())
.isEqualTo(url("https://example.com/acme/cert/1234"));
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
softly.assertThat(order.getExpires().orElseThrow()).isEqualTo("2015-03-01T14:09:00Z");
softly.assertThat(requestWasSent).isFalse();
}
provider.close();
}
/**
* Test that order is properly finalized.
*/
@Test
public void testFinalize() throws Exception {
var csr = TestUtils.getResourceAsByteArray("/csr.der");
var provider = new TestableConnectionProvider() {
private boolean isFinalized = false;
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(finalizeUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("finalizeRequest").toString());
assertThat(login).isNotNull();
isFinalized = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse");
}
};
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.execute(csr);
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getStatus()).isEqualTo(Status.VALID);
softly.assertThat(order.getExpires().orElseThrow()).isEqualTo("2015-03-01T14:09:00Z");
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
Identifier.dns("example.com"),
Identifier.dns("www.example.com"));
softly.assertThat(order.getNotBefore().orElseThrow())
.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.assertThat(order.getFinalizeLocation()).isEqualTo(finalizeUrl);
var auths = order.getAuthorizations();
softly.assertThat(auths).hasSize(2);
softly.assertThat(auths.stream())
.map(Authorization::getLocation)
.containsExactlyInAnyOrder(
url("https://example.com/acme/authz/1234"),
url("https://example.com/acme/authz/2345"));
}
provider.close();
}
/**
* Test that order is properly updated.
*/
@Test
public void testAutoRenewUpdate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateAutoRenewOrderResponse");
}
};
provider.putMetadata("auto-renewal", JSON.empty());
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.fetch();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.isAutoRenewing()).isTrue();
softly.assertThat(order.getAutoRenewalStartDate().orElseThrow())
.isEqualTo("2016-01-01T00:00:00Z");
softly.assertThat(order.getAutoRenewalEndDate())
.isEqualTo("2017-01-01T00:00:00Z");
softly.assertThat(order.getAutoRenewalLifetime())
.isEqualTo(Duration.ofHours(168));
softly.assertThat(order.getAutoRenewalLifetimeAdjust().orElseThrow())
.isEqualTo(Duration.ofDays(6));
softly.assertThat(order.getNotBefore()).isEmpty();
softly.assertThat(order.getNotAfter()).isEmpty();
softly.assertThat(order.isAutoRenewalGetEnabled()).isTrue();
}
provider.close();
}
/**
* Test that auto-renew order is properly finalized.
*/
@Test
public void testAutoRenewFinalize() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("finalizeAutoRenewResponse");
}
};
var login = provider.createLogin();
var order = login.bindOrder(locationUrl);
try (var softly = new AutoCloseableSoftAssertions()) {
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())
.isEqualTo("2018-01-01T00:00:00Z");
softly.assertThat(order.getAutoRenewalEndDate())
.isEqualTo("2019-01-01T00:00:00Z");
softly.assertThat(order.getAutoRenewalLifetime())
.isEqualTo(Duration.ofHours(168));
softly.assertThat(order.getAutoRenewalLifetimeAdjust().orElseThrow())
.isEqualTo(Duration.ofDays(6));
softly.assertThat(order.getNotBefore()).isEmpty();
softly.assertThat(order.getNotAfter()).isEmpty();
softly.assertThat(order.isAutoRenewalGetEnabled()).isTrue();
}
provider.close();
}
/**
* Test that auto-renew order is properly canceled.
*/
@Test
public void testCancel() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
var json = claims.toJSON();
assertThat(json.get("status").asString()).isEqualTo("canceled");
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("canceledOrderResponse");
}
};
provider.putMetadata("auto-renewal", JSON.empty());
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.cancelAutoRenewal();
assertThat(order.getStatus()).isEqualTo(Status.CANCELED);
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/ProblemTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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 net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Problem}.
*/
public class ProblemTest {
@Test
public void testProblem() {
var baseUrl = url("https://example.com/acme/1");
var original = TestUtils.getJSON("problem");
var problem = new Problem(original, baseUrl);
assertThatJson(problem.asJSON().toString()).isEqualTo(original.toString());
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(problem.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:malformed"));
softly.assertThat(problem.getTitle().orElseThrow())
.isEqualTo("Some of the identifiers requested were rejected");
softly.assertThat(problem.getDetail().orElseThrow())
.isEqualTo("Identifier \"abc12_\" is malformed");
softly.assertThat(problem.getInstance().orElseThrow())
.isEqualTo(URI.create("https://example.com/documents/error.html"));
softly.assertThat(problem.getIdentifier()).isEmpty();
softly.assertThat(problem.toString()).isEqualTo(
"Identifier \"abc12_\" is malformed ("
+ "Invalid underscore in DNS name \"_example.com\" ‒ "
+ "This CA will not issue for \"example.net\")");
var subs = problem.getSubProblems();
softly.assertThat(subs).isNotNull().hasSize(2);
var p1 = subs.get(0);
softly.assertThat(p1.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:malformed"));
softly.assertThat(p1.getTitle()).isEmpty();
softly.assertThat(p1.getDetail().orElseThrow())
.isEqualTo("Invalid underscore in DNS name \"_example.com\"");
softly.assertThat(p1.getIdentifier().orElseThrow().getDomain()).isEqualTo("_example.com");
softly.assertThat(p1.toString()).isEqualTo("Invalid underscore in DNS name \"_example.com\"");
var p2 = subs.get(1);
softly.assertThat(p2.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:rejectedIdentifier"));
softly.assertThat(p2.getTitle()).isEmpty();
softly.assertThat(p2.getDetail().orElseThrow())
.isEqualTo("This CA will not issue for \"example.net\"");
softly.assertThat(p2.getIdentifier().orElseThrow().getDomain()).isEqualTo("example.net");
softly.assertThat(p2.toString()).isEqualTo("This CA will not issue for \"example.net\"");
}
}
/**
* Test that {@link Problem#toString()} always returns the most specific message.
*/
@Test
public void testToString() {
var baseUrl = url("https://example.com/acme/1");
var typeUri = URI.create("urn:ietf:params:acme:error:malformed");
var jb = new JSONBuilder();
jb.put("type", typeUri);
var p1 = new Problem(jb.toJSON(), baseUrl);
assertThat(p1.toString()).isEqualTo(typeUri.toString());
jb.put("title", "Some of the identifiers requested were rejected");
var p2 = new Problem(jb.toJSON(), baseUrl);
assertThat(p2.toString()).isEqualTo("Some of the identifiers requested were rejected");
jb.put("detail", "Identifier \"abc12_\" is malformed");
var p3 = new Problem(jb.toJSON(), baseUrl);
assertThat(p3.toString()).isEqualTo("Identifier \"abc12_\" is malformed");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.Mockito.mock;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.HttpURLConnection;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Unit test for {@link RenewalInfo}.
*/
public class RenewalInfoTest {
private final URL locationUrl = url("http://example.com/acme/renewalInfo/1234");
private final Instant retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS);
private final Instant startWindow = Instant.parse("2021-01-03T00:00:00Z");
private final Instant endWindow = Instant.parse("2021-01-07T00:00:00Z");
@Test
public void testGetters() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) {
assertThat(url).isEqualTo(locationUrl);
assertThat(session).isNotNull();
assertThat(ifModifiedSince).isNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("renewalInfo");
}
@Override
public Optional getRetryAfter() {
return Optional.of(retryAfterInstant);
}
};
var login = provider.createLogin();
var renewalInfo = new RenewalInfo(login, locationUrl);
var recheckAfter = renewalInfo.fetch();
assertThat(recheckAfter).hasValue(retryAfterInstant);
// Check getters
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl);
softly.assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo(startWindow);
softly.assertThat(renewalInfo.getSuggestedWindowEnd())
.isEqualTo(endWindow);
softly.assertThat(renewalInfo.getExplanation())
.isNotEmpty()
.contains(url("https://example.com/docs/example-mass-reissuance-event"));
}
// Check renewalIsNotRequired
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.renewalIsNotRequired(startWindow.minusSeconds(1L)))
.isTrue();
softly.assertThat(renewalInfo.renewalIsNotRequired(startWindow))
.isFalse();
softly.assertThat(renewalInfo.renewalIsNotRequired(endWindow.minusSeconds(1L)))
.isFalse();
softly.assertThat(renewalInfo.renewalIsNotRequired(endWindow))
.isFalse();
}
// Check renewalIsRecommended
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.renewalIsRecommended(startWindow.minusSeconds(1L)))
.isFalse();
softly.assertThat(renewalInfo.renewalIsRecommended(startWindow))
.isTrue();
softly.assertThat(renewalInfo.renewalIsRecommended(endWindow.minusSeconds(1L)))
.isTrue();
softly.assertThat(renewalInfo.renewalIsRecommended(endWindow))
.isFalse();
}
// Check renewalIsOverdue
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.renewalIsOverdue(startWindow.minusSeconds(1L)))
.isFalse();
softly.assertThat(renewalInfo.renewalIsOverdue(startWindow))
.isFalse();
softly.assertThat(renewalInfo.renewalIsOverdue(endWindow.minusSeconds(1L)))
.isFalse();
softly.assertThat(renewalInfo.renewalIsOverdue(endWindow))
.isTrue();
}
// Check getRandomProposal, is empty because end window is in the past
var proposal = renewalInfo.getRandomProposal(null);
assertThat(proposal).isEmpty();
provider.close();
}
@Test
public void testRandomProposal() {
var login = mock(Login.class);
var start = Instant.now();
var end = start.plus(1L, ChronoUnit.DAYS);
var renewalInfo = new RenewalInfo(login, locationUrl) {
@Override
public Instant getSuggestedWindowStart() {
return start;
}
@Override
public Instant getSuggestedWindowEnd() {
return end;
}
};
var noFreq = renewalInfo.getRandomProposal(null);
assertThat(noFreq).isNotEmpty();
assertThat(noFreq.get()).isBetween(start, end);
var oneHour = renewalInfo.getRandomProposal(Duration.ofHours(1L));
assertThat(oneHour).isNotEmpty();
assertThat(oneHour.get()).isBetween(start, end.minus(1L, ChronoUnit.HOURS));
var twoDays = renewalInfo.getRandomProposal(Duration.ofDays(2L));
assertThat(twoDays).isEmpty();
}
@Test
public void testDateAssertion() {
var login = mock(Login.class);
var start = Instant.now();
var end = start.minusSeconds(1L); // end before start
var renewalInfo = new RenewalInfo(login, locationUrl) {
@Override
public Instant getSuggestedWindowStart() {
return start;
}
@Override
public Instant getSuggestedWindowEnd() {
return end;
}
};
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> renewalInfo.renewalIsRecommended(start));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/SessionTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static org.shredzone.acme4j.toolbox.TestUtils.*;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Locale;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.provider.GenericAcmeProvider;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit test for {@link Session}.
*/
public class SessionTest {
/**
* Test constructor
*/
@Test
public void testConstructor() {
var serverUri = URI.create(TestUtils.ACME_SERVER_URI);
assertThrows(NullPointerException.class, () -> new Session((URI) null));
var session = new Session(serverUri);
assertThat(session).isNotNull();
assertThat(session.getServerUri()).isEqualTo(serverUri);
var session2 = new Session(TestUtils.ACME_SERVER_URI);
assertThat(session2).isNotNull();
assertThat(session2.getServerUri()).isEqualTo(serverUri);
var session3 = new Session(serverUri, new GenericAcmeProvider());
assertThat(session3).isNotNull();
assertThat(session3.getServerUri()).isEqualTo(serverUri);
assertThrows(IllegalArgumentException.class,
() -> new Session("#*aBaDuRi*#"),
"Bad URI in constructor");
assertThrows(IllegalArgumentException.class,
() -> new Session(URI.create("acme://invalid"), new GenericAcmeProvider()),
"Unsupported URI");
}
/**
* Test getters and setters.
*/
@Test
public void testGettersAndSetters() {
var serverUri = URI.create(TestUtils.ACME_SERVER_URI);
var now = ZonedDateTime.now();
var session = new Session(serverUri);
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isNull();
nonceHolder.setNonce(DUMMY_NONCE);
assertThat(nonceHolder.getNonce()).isEqualTo(DUMMY_NONCE);
}
assertThat(session.getServerUri()).isEqualTo(serverUri);
assertThat(session.networkSettings()).isNotNull();
assertThat(session.getDirectoryExpires()).isNull();
session.setDirectoryExpires(now);
assertThat(session.getDirectoryExpires()).isEqualTo(now);
session.setDirectoryExpires(null);
assertThat(session.getDirectoryExpires()).isNull();
assertThat(session.getDirectoryLastModified()).isNull();
session.setDirectoryLastModified(now);
assertThat(session.getDirectoryLastModified()).isEqualTo(now);
session.setDirectoryLastModified(null);
assertThat(session.getDirectoryLastModified()).isNull();
session.setDirectoryExpires(now);
session.setDirectoryLastModified(now);
session.purgeDirectoryCache();
assertThat(session.getDirectoryExpires()).isNull();
assertThat(session.getDirectoryLastModified()).isNull();
assertThat(session.hasDirectory()).isFalse();
}
/**
* Test login methods.
*/
@Test
public void testLogin() throws IOException {
var serverUri = URI.create(TestUtils.ACME_SERVER_URI);
var accountLocation = url(TestUtils.ACCOUNT_URL);
var accountKeyPair = TestUtils.createKeyPair();
var session = new Session(serverUri);
var login = session.login(accountLocation, accountKeyPair);
assertThat(login).isNotNull();
assertThat(login.getSession()).isEqualTo(session);
assertThat(login.getAccount().getLocation()).isEqualTo(accountLocation);
assertThat(login.getPublicKey()).isEqualTo(accountKeyPair.getPublic());
}
/**
* Test that the directory is properly read.
*/
@Test
public void testDirectory() throws AcmeException, IOException {
var serverUri = URI.create(TestUtils.ACME_SERVER_URI);
var mockProvider = mock(AcmeProvider.class);
when(mockProvider.directory(
ArgumentMatchers.any(Session.class),
ArgumentMatchers.eq(serverUri)))
.thenReturn(getJSON("directory"));
var session = new Session(serverUri) {
@Override
public AcmeProvider provider() {
return mockProvider;
}
};
// No directory has been fetched yet
assertThat(session.hasDirectory()).isFalse();
assertThat(session.resourceUrl(Resource.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(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrl(Resource.NEW_ORDER))
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(() -> session.resourceUrl(Resource.REVOKE_CERT))
.withMessage("Server does not support revokeCert");
assertThat(session.resourceUrlOptional(Resource.NEW_AUTHZ))
.isNotEmpty()
.contains(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrlOptional(Resource.REVOKE_CERT))
.isEmpty();
var meta = session.getMetadata();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(meta).isNotNull();
softly.assertThat(meta.getTermsOfService().orElseThrow())
.isEqualTo(URI.create("https://example.com/acme/terms"));
softly.assertThat(meta.getWebsite().orElseThrow().toExternalForm())
.isEqualTo("https://www.example.com/");
softly.assertThat(meta.getCaaIdentities()).containsExactlyInAnyOrder("example.com");
softly.assertThat(meta.isAutoRenewalEnabled()).isTrue();
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();
}
// Make sure directory is read
verify(mockProvider, atLeastOnce()).directory(
ArgumentMatchers.any(Session.class),
ArgumentMatchers.any(URI.class));
}
/**
* Test that the directory is properly read even if there are no metadata.
*/
@Test
public void testNoMeta() throws AcmeException, IOException {
var serverUri = URI.create(TestUtils.ACME_SERVER_URI);
var mockProvider = mock(AcmeProvider.class);
when(mockProvider.directory(
ArgumentMatchers.any(Session.class),
ArgumentMatchers.eq(serverUri)))
.thenReturn(getJSON("directoryNoMeta"));
var session = new Session(serverUri) {
@Override
public AcmeProvider provider() {
return mockProvider;
}
};
assertThat(session.resourceUrl(Resource.NEW_ACCOUNT))
.isEqualTo(URI.create("https://example.com/acme/new-account").toURL());
assertThat(session.resourceUrl(Resource.NEW_AUTHZ))
.isEqualTo(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrl(Resource.NEW_ORDER))
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
var meta = session.getMetadata();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(meta).isNotNull();
softly.assertThat(meta.getTermsOfService()).isEmpty();
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();
}
}
/**
* Test that the locale is properly set.
*/
@Test
public void testLocale() {
var session = new Session(URI.create(TestUtils.ACME_SERVER_URI));
// default configuration
assertThat(session.getLocale())
.isEqualTo(Locale.getDefault());
assertThat(session.getLanguageHeader())
.isEqualTo(AcmeUtils.localeToLanguageHeader(Locale.getDefault()));
// null
session.setLocale(null);
assertThat(session.getLocale()).isNull();
assertThat(session.getLanguageHeader()).isEqualTo("*");
// a locale
session.setLocale(Locale.CANADA_FRENCH);
assertThat(session.getLocale()).isEqualTo(Locale.CANADA_FRENCH);
assertThat(session.getLanguageHeader()).isEqualTo("fr-CA,fr;q=0.8,*;q=0.1");
}
/**
* Test that getHttpClient returns a shared client instance.
*/
@Test
public void testGetHttpClientWithReuse() {
var session = new Session(URI.create(TestUtils.ACME_SERVER_URI));
var client1 = session.getHttpClient();
var client2 = session.getHttpClient();
// Both calls should return the same client instance
assertThat(client1).isSameAs(client2);
}
/**
* Test that getHttpClient is thread-safe.
*/
@Test
public void testGetHttpClientThreadSafety() throws Exception {
var session = new Session(URI.create(TestUtils.ACME_SERVER_URI));
var threads = new Thread[10];
var clients = new java.net.http.HttpClient[threads.length];
for (int i = 0; i < threads.length; i++) {
final int index = i;
threads[i] = new Thread(() -> {
clients[index] = session.getHttpClient();
});
}
for (var thread : threads) {
thread.start();
}
for (var thread : threads) {
thread.join();
}
// All threads should get the same client instance
var firstClient = clients[0];
for (var client : clients) {
assertThat(client).isSameAs(firstClient);
}
}
/**
* Test that connections from the same session share the same HttpClient.
*/
@Test
public void testConnectionsShareHttpClient() throws AcmeException {
var session = new Session(URI.create(TestUtils.ACME_SERVER_URI));
var conn1 = session.connect();
var conn2 = session.connect();
// Both connections should use the same HttpClient from the session
var client1 = session.getHttpClient();
var client2 = session.getHttpClient();
assertThat(client1).isSameAs(client2);
conn1.close();
conn2.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/StatusTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Locale;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link Status} enumeration.
*/
public class StatusTest {
/**
* Test that invoking {@link Status#parse(String)} gives the correct status.
*/
@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(Locale.ENGLISH));
assertThat(parsed).isEqualTo(s);
}
// unknown status returns UNKNOWN
assertThat(Status.parse("foo")).isEqualTo(Status.UNKNOWN);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.within;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.HttpURLConnection;
import java.net.URI;
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.AcmeProtocolException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Challenge}.
*/
public class ChallengeTest {
private final URL locationUrl = url("https://example.com/acme/some-location");
/**
* Test that after unmarshaling, the challenge properties are set correctly.
*/
@Test
public void testUnmarshal() {
var challenge = new Challenge(TestUtils.login(), getJSON("genericChallenge"));
// Test unmarshalled values
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(challenge.getType()).isEqualTo("generic-01");
softly.assertThat(challenge.getStatus()).isEqualTo(Status.INVALID);
softly.assertThat(challenge.getLocation()).isEqualTo(url("http://example.com/challenge/123"));
softly.assertThat(challenge.getValidated().orElseThrow())
.isCloseTo("2015-12-12T17:19:36.336Z", within(1, ChronoUnit.MILLIS));
softly.assertThat(challenge.getJSON().get("type").asString()).isEqualTo("generic-01");
softly.assertThat(challenge.getJSON().get("url").asURL()).isEqualTo(url("http://example.com/challenge/123"));
var error = challenge.getError().orElseThrow();
softly.assertThat(error.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:incorrectResponse"));
softly.assertThat(error.getDetail().orElseThrow()).isEqualTo("bad token");
softly.assertThat(error.getInstance().orElseThrow())
.isEqualTo(URI.create("http://example.com/documents/faq.html"));
}
}
/**
* Test that {@link Challenge#prepareResponse(JSONBuilder)} contains the type.
*/
@Test
public void testRespond() {
var challenge = new Challenge(TestUtils.login(), getJSON("genericChallenge"));
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
/**
* Test that an exception is thrown on challenge type mismatch.
*/
@Test
public void testNotAcceptable() {
assertThrows(AcmeProtocolException.class, () ->
new Http01Challenge(TestUtils.login(), getJSON("dns01Challenge"))
);
}
/**
* Test that a challenge can be triggered.
*/
@Test
public void testTrigger() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("triggerHttpChallengeRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("triggerHttpChallengeResponse");
}
};
var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallenge"));
challenge.trigger();
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
provider.close();
}
/**
* Test that a challenge is properly updated.
*/
@Test
public void testUpdate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateHttpChallengeResponse");
}
};
var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
challenge.fetch();
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
provider.close();
}
/**
* Test that a challenge is properly updated, with Retry-After header.
*/
@Test
public void testUpdateRetryAfter() throws Exception {
var retryAfter = Instant.now().plus(Duration.ofSeconds(30));
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
return getJSON("updateHttpChallengeResponse");
}
@Override
public Optional getRetryAfter() {
return Optional.of(retryAfter);
}
};
var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
var returnedRetryAfter = challenge.fetch();
assertThat(returnedRetryAfter).hasValue(retryAfter);
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
provider.close();
}
/**
* Test that unmarshalling something different like a challenge fails.
*/
@Test
public void testBadUnmarshall() {
assertThrows(AcmeProtocolException.class, () ->
new Challenge(TestUtils.login(), getJSON("updateAccountResponse"))
);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Dns01ChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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 Dns01Challenge}.
*/
public class Dns01ChallengeTest {
private final Login login = TestUtils.login();
/**
* Test that {@link Dns01Challenge} generates a correct authorization key.
*/
@Test
public void testDnsChallenge() {
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("{}");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsAccount01ChallengeTest.java
================================================
/*
* 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("{}");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/DnsPersist01ChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2026 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.*;
import static org.shredzone.acme4j.toolbox.TestUtils.ACCOUNT_URL;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import java.time.Instant;
import java.util.TreeMap;
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.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link DnsPersist01Challenge}.
*/
class DnsPersist01ChallengeTest {
private final Login login = TestUtils.login();
/**
* Test that {@link DnsPersist01Challenge} generates a correct TXT record.
*/
@Test
public void testDnsChallenge() {
var challenge = new DnsPersist01Challenge(login, getJSON("dnsPersist01Challenge"));
assertThat(challenge.getType()).isEqualTo(DnsPersist01Challenge.TYPE);
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getIssuerDomainNames()).containsExactly("authority.example", "ca.example.net");
assertThat(challenge.getRRName("www.example.org"))
.isEqualTo("_validation-persist.www.example.org.");
assertThat(challenge.getRRName(Identifier.dns("www.example.org")))
.isEqualTo("_validation-persist.www.example.org.");
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10")));
assertThat(challenge.getRData())
.isEqualTo("\"authority.example;\" \" accounturi=" + ACCOUNT_URL + "\"");
assertThat(challenge.getAccountUrl().toString())
.isEqualTo(ACCOUNT_URL);
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
/**
* Test that {@link DnsPersist01Challenge} generates a correct TXT record.
*/
@Test
public void testBuilder() {
var challenge = new DnsPersist01Challenge(login, getJSON("dnsPersist01Challenge"));
var until = Instant.ofEpochSecond(1767225600L);
assertThat(challenge.buildRData().build())
.isEqualTo("\"authority.example;\" \" accounturi=" + ACCOUNT_URL + "\"");
assertThat(challenge.buildRData().wildcard().build())
.isEqualTo("\"authority.example;\" \" accounturi=" + ACCOUNT_URL + ";\" \" policy=wildcard\"");
assertThat(challenge.buildRData().issuerDomainName("ca.example.net").build())
.isEqualTo("\"ca.example.net;\" \" accounturi=" + ACCOUNT_URL + "\"");
assertThat(challenge.buildRData().persistUntil(until).build())
.isEqualTo("\"authority.example;\" \" accounturi=" + ACCOUNT_URL + ";\" \" persistUntil=1767225600\"");
assertThat(challenge.buildRData()
.wildcard()
.issuerDomainName("ca.example.net")
.persistUntil(until)
.build()
).isEqualTo("\"ca.example.net;\" \" accounturi=" + ACCOUNT_URL + ";\" \" policy=wildcard;\" \" persistUntil=1767225600\"");
assertThatIllegalArgumentException()
.isThrownBy(() -> challenge.buildRData().issuerDomainName("ca.invalid").build())
.withMessage("Domain ca.invalid is not in the list of issuer-domain-names");
}
/**
* Test that {@link DnsPersist01Challenge} generates a correct TXT record, without
* quotes.
*/
@Test
public void testBuilderNoQuotes() {
var challenge = new DnsPersist01Challenge(login, getJSON("dnsPersist01Challenge"));
var until = Instant.ofEpochSecond(1767225600L);
assertThat(challenge.buildRData().noQuotes().build())
.isEqualTo("authority.example; accounturi=" + ACCOUNT_URL);
assertThat(challenge.buildRData()
.wildcard()
.issuerDomainName("ca.example.net")
.persistUntil(until)
.noQuotes()
.build()
).isEqualTo("ca.example.net; accounturi=" + ACCOUNT_URL + "; policy=wildcard; persistUntil=1767225600");
}
@Test
public void testConstraintChecks() {
var json = getJSON("dnsPersist01Challenge").toMap();
// Must fail if issuer-domain-names is missing
var json1 = new TreeMap<>(json);
json1.remove("issuer-domain-names");
var challenge1 = new DnsPersist01Challenge(login, JSON.fromMap(json1));
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(challenge1::getIssuerDomainNames)
.withMessage("issuer-domain-names missing or empty");
// Must fail if issuer-domain-names is empty
var json2 = new TreeMap<>(json);
json2.put("issuer-domain-names", new String[0]);
var challenge2 = new DnsPersist01Challenge(login, JSON.fromMap(json2));
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(challenge2::getIssuerDomainNames)
.withMessage("issuer-domain-names missing or empty");
// Must not fail if issuer-domain-names contains exactly 10 records
var json3 = new TreeMap<>(json);
json3.put("issuer-domain-names", createDomainList(10));
var challenge3 = new DnsPersist01Challenge(login, JSON.fromMap(json3));
assertThatNoException()
.isThrownBy(challenge3::getIssuerDomainNames);
// Must fail if issuer-domain-names contains more than 10 records
var json4 = new TreeMap<>(json);
json4.put("issuer-domain-names", createDomainList(11));
var challenge4 = new DnsPersist01Challenge(login, JSON.fromMap(json4));
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(challenge4::getIssuerDomainNames)
.withMessage("issuer-domain-names size limit exceeded: 11 > 10");
// Must fail if issuer-domain-names contains a trailing dot
var json5 = new TreeMap<>(json);
json5.put("issuer-domain-names", new String[] {"foo.example.com."});
var challenge5 = new DnsPersist01Challenge(login, JSON.fromMap(json5));
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(challenge5::getIssuerDomainNames)
.withMessage("issuer-domain-names must not have trailing dots");
// Must fail if accounturi is wrong
var json6 = new TreeMap<>(json);
json6.put("accounturi", "https://wrong.example.com/bad/account/1234");
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> new DnsPersist01Challenge(login, JSON.fromMap(json6)))
.withMessage("challenge is intended for a different account: https://wrong.example.com/bad/account/1234");
// Must not fail at the moment if accounturi is missing
// (downward compatiblilty for draft-ietf-acme-dns-persist-00)
var json7 = new TreeMap<>(json);
json7.remove("accounturi");
assertThatNoException()
.isThrownBy(() -> new DnsPersist01Challenge(login, JSON.fromMap(json7)));
}
private String[] createDomainList(int length) {
var result = new String[length];
for (var ix = 0; ix < length; ix++) {
result[ix] = "foo" + ix + ".example.com";
}
return result;
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/Http01ChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import org.junit.jupiter.api.Test;
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 Http01Challenge}.
*/
public class Http01ChallengeTest {
private static final String TOKEN =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ";
private static final String KEY_AUTHORIZATION =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0";
private final Login login = TestUtils.login();
/**
* Test that {@link Http01Challenge} generates a correct authorization key.
*/
@Test
public void testHttpChallenge() {
var challenge = new Http01Challenge(login, getJSON("httpChallenge"));
assertThat(challenge.getType()).isEqualTo(Http01Challenge.TYPE);
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getToken()).isEqualTo(TOKEN);
assertThat(challenge.getAuthorization()).isEqualTo(KEY_AUTHORIZATION);
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
/**
* Test that an exception is thrown if there is no token.
*/
@Test
public void testNoTokenSet() {
assertThrows(AcmeProtocolException.class, () -> {
Http01Challenge challenge = new Http01Challenge(login, getJSON("httpNoTokenChallenge"));
challenge.getToken();
});
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TlsAlpn01ChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.assertThatNoException;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import java.security.cert.CertificateParsingException;
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.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
import org.shredzone.acme4j.util.KeyPairUtils;
/**
* Unit tests for {@link TlsAlpn01ChallengeTest}.
*/
public class TlsAlpn01ChallengeTest {
private static final String TOKEN =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ";
private static final String KEY_AUTHORIZATION =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0";
private final Login login = TestUtils.login();
/**
* Test that {@link TlsAlpn01Challenge} generates a correct authorization key.
*/
@Test
public void testTlsAlpn01Challenge() {
var challenge = new TlsAlpn01Challenge(login, getJSON("tlsAlpnChallenge"));
assertThat(challenge.getType()).isEqualTo(TlsAlpn01Challenge.TYPE);
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getToken()).isEqualTo(TOKEN);
assertThat(challenge.getAuthorization()).isEqualTo(KEY_AUTHORIZATION);
assertThat(challenge.getAcmeValidation()).isEqualTo(AcmeUtils.sha256hash(KEY_AUTHORIZATION));
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
/**
* Test that {@link TlsAlpn01Challenge} generates a correct test certificate
*/
@Test
public void testTlsAlpn01Certificate() throws CertificateParsingException {
var challenge = new TlsAlpn01Challenge(login, getJSON("tlsAlpnChallenge"));
var keypair = KeyPairUtils.createKeyPair(2048);
var subject = Identifier.dns("example.com");
var certificate = challenge.createCertificate(keypair, subject);
// Only check the main requirements. Cert generation is fully tested in CertificateUtilsTest.
assertThat(certificate).isNotNull();
assertThat(certificate.getSubjectX500Principal().getName()).isEqualTo("CN=acme.invalid");
assertThat(certificate.getSubjectAlternativeNames().stream()
.map(l -> l.get(1))
.map(Object::toString)).contains(subject.getDomain());
assertThat(certificate.getCriticalExtensionOIDs()).contains(TlsAlpn01Challenge.ACME_VALIDATION_OID);
assertThatNoException().isThrownBy(() -> certificate.verify(keypair.getPublic()));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/challenge/TokenChallengeTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2019 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.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Unit tests for {@link TokenChallenge}.
*/
public class TokenChallengeTest {
/**
* Test that invalid tokens are detected.
*/
@Test
public void testInvalidToken() throws IOException {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
var jb = new JSONBuilder();
jb.put("url", "https://example.com/acme/1234");
jb.put("type", "generic");
jb.put("token", "");
var challenge = new TokenChallenge(login, jb.toJSON());
assertThrows(AcmeProtocolException.class, challenge::getToken);
provider.close();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.time.temporal.ChronoUnit.SECONDS;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.getResourceAsByteArray;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.ByteArrayOutputStream;
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;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwx.CompactSerializer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Login;
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.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtils;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link DefaultConnection}.
*/
@WireMockTest
public class DefaultConnectionTest {
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
private static final String DIRECTORY_PATH = "/dir";
private static final String NEW_NONCE_PATH = "/newNonce";
private static final String REQUEST_PATH = "/test/test";
private static final String TEST_ACCEPT_LANGUAGE = "ja-JP,ja;q=0.8,*;q=0.1";
private static final String TEST_ACCEPT_CHARSET = "utf-8";
private static final String TEST_USER_AGENT_PATTERN = "^acme4j/.*$";
private final URL accountUrl = TestUtils.url(TestUtils.ACCOUNT_URL);
private Session session;
private Login login;
private KeyPair keyPair;
private String baseUrl;
private URL directoryUrl;
private URL newNonceUrl;
private URL requestUrl;
@BeforeEach
public void setup(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
baseUrl = wmRuntimeInfo.getHttpBaseUrl();
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);
keyPair = TestUtils.createKeyPair();
login = session.login(accountUrl, keyPair);
var directory = new JSONBuilder();
directory.put("newNonce", newNonceUrl);
stubFor(get(DIRECTORY_PATH).willReturn(okJson(directory.toString())));
}
/**
* Test that {@link DefaultConnection#getNonce()} is empty if there is no
* {@code Replay-Nonce} header.
*/
@Test
public void testNoNonceFromHeader() throws AcmeException {
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()));
try (var nonceHolder = session.lockNonce(); var conn = session.connect()) {
assertThat(nonceHolder.getNonce()).isNull();
conn.sendRequest(directoryUrl, session, null);
assertThat(conn.getNonce()).isEmpty();
}
}
/**
* Test that {@link DefaultConnection#getNonce()} extracts a {@code Replay-Nonce}
* header correctly.
*/
@Test
public void testGetNonceFromHeader() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", TestUtils.DUMMY_NONCE)
));
try (var nonceHolder = session.lockNonce(); var conn = session.connect()) {
assertThat(nonceHolder.getNonce()).isNull();
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getNonce().orElseThrow()).isEqualTo(TestUtils.DUMMY_NONCE);
assertThat(nonceHolder.getNonce()).isEqualTo(TestUtils.DUMMY_NONCE);
}
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!
));
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.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!
));
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.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.
*/
@Test
public void testInvalidNonceFromHeader() {
var badNonce = "#$%&/*+*#'";
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", badNonce)
));
var ex = assertThrows(AcmeProtocolException.class, () -> {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
conn.getNonce();
}
});
assertThat(ex.getMessage()).startsWith("Invalid replay nonce");
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
}
/**
* Test that {@link DefaultConnection#resetNonce(Session)} fetches a new nonce via
* new-nonce resource and a HEAD request.
*/
@Test
public void testResetNonceSucceedsIfNoncePresent() throws AcmeException {
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", TestUtils.DUMMY_NONCE)
));
try (var nonceHolder = session.lockNonce(); var conn = session.connect()) {
assertThat(nonceHolder.getNonce()).isNull();
conn.resetNonce(session);
assertThat(nonceHolder.getNonce()).isEqualTo(TestUtils.DUMMY_NONCE);
}
}
/**
* Test that {@link DefaultConnection#resetNonce(Session)} throws an exception if
* there is no nonce header.
*/
@Test
public void testResetNonceThrowsException() {
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()));
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isNull();
assertThrows(AcmeProtocolException.class, () -> {
try (var conn = session.connect()) {
conn.resetNonce(session);
}
});
assertThat(nonceHolder.getNonce()).isNull();
}
}
/**
* Test that an absolute Location header is evaluated.
*/
@Test
public void testGetAbsoluteLocation() throws Exception {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Location", "https://example.com/otherlocation")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var location = conn.getLocation();
assertThat(location).isEqualTo(URI.create("https://example.com/otherlocation").toURL());
}
}
/**
* Test that a relative Location header is evaluated.
*/
@Test
public void testGetRelativeLocation() throws Exception {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Location", "/otherlocation")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var location = conn.getLocation();
assertThat(location).isEqualTo(URI.create(baseUrl + "/otherlocation").toURL());
}
}
/**
* Test that absolute and relative Link headers are evaluated.
*/
@Test
public void testGetLink() throws Exception {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Link", ";rel=\"next\"")
.withHeader("Link", ";rel=recover")
.withHeader("Link", "; rel=\"terms-of-service\"")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
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();
}
}
/**
* Test that multiple link headers are evaluated.
*/
@Test
public void testGetMultiLink() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Link", "; rel=\"terms-of-service\"")
.withHeader("Link", "; rel=\"terms-of-service\"")
.withHeader("Link", "<../terms3>; rel=\"terms-of-service\"")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLinks("terms-of-service")).containsExactlyInAnyOrder(
url("https://example.com/acme/terms1"),
url("https://example.com/acme/terms2"),
url(baseUrl + "/terms3")
);
}
}
/**
* Test that link headers with multiple header fields are evaluated
*/
@Test
public void testGetMultiHeaderFieldLink() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Link", "; rel=\"terms-of-service\"; title=\"Please Read\"")
.withHeader("Link", "; title=\"Please read and accept\"; rel=\"terms-of-service\"")
.withHeader("Link", "<../terms3>; anchor=\"foo\"; rel=\"terms-of-service\" ; title=\"More ToS to read\"")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLinks("terms-of-service")).containsExactlyInAnyOrder(
url("https://example.com/acme/terms1"),
url("https://example.com/acme/terms2"),
url(baseUrl + "/terms3")
);
}
}
/**
* Test that no link headers are properly handled.
*/
@Test
public void testGetNoLink() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLinks("something")).isEmpty();
}
}
/**
* Test that no Location header returns {@code null}.
*/
@Test
public void testNoLocation() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(conn::getLocation);
}
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
}
/**
* Test if Retry-After header with absolute date is correctly parsed.
*/
@Test
public void testHandleRetryAfterHeaderDate() throws AcmeException {
var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS);
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Retry-After", DATE_FORMATTER.format(retryDate))
));
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() throws AcmeException {
var delta = 10 * 60 * 60;
var now = Instant.now().truncatedTo(SECONDS);
var retryMsg = "relative time";
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Retry-After", String.valueOf(delta))
.withHeader("Date", DATE_FORMATTER.format(now))
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta));
}
}
/**
* Test if no Retry-After header is correctly handled.
*/
@Test
public void testHandleRetryAfterHeaderNull() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Date", DATE_FORMATTER.format(Instant.now()))
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getRetryAfter()).isEmpty();
}
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
}
/**
* Test if no exception is thrown on a standard request.
*/
@Test
public void testAccept() throws AcmeException {
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withBody("")
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
try (var conn = session.connect()) {
var rc = conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
assertThat(rc).isEqualTo(HttpURLConnection.HTTP_OK);
}
verify(postRequestedFor(urlEqualTo(REQUEST_PATH)));
}
/**
* Test if an {@link AcmeServerException} is thrown on an acme problem.
*/
@Test
public void testAcceptThrowsException() {
var problem = new JSONBuilder();
problem.put("type", "urn:ietf:params:acme:error:unauthorized");
problem.put("detail", "Invalid response: 404");
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_FORBIDDEN)
.withHeader("Content-Type", "application/problem+json")
.withBody(problem.toString())
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex).isInstanceOf(AcmeUnauthorizedException.class);
assertThat(((AcmeUnauthorizedException) ex).getType())
.isEqualTo(URI.create("urn:ietf:params:acme:error:unauthorized"));
assertThat(ex.getMessage()).isEqualTo("Invalid response: 404");
}
/**
* Test if an {@link AcmeUserActionRequiredException} is thrown on an acme problem.
*/
@Test
public void testAcceptThrowsUserActionRequiredException() {
var problem = new JSONBuilder();
problem.put("type", "urn:ietf:params:acme:error:userActionRequired");
problem.put("detail", "Accept the TOS");
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_FORBIDDEN)
.withHeader("Content-Type", "application/problem+json")
.withHeader("Link", "; rel=\"terms-of-service\"")
.withBody(problem.toString())
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex).isInstanceOf(AcmeUserActionRequiredException.class);
assertThat(((AcmeUserActionRequiredException) ex).getType())
.isEqualTo(URI.create("urn:ietf:params:acme:error:userActionRequired"));
assertThat(ex.getMessage()).isEqualTo("Accept the TOS");
assertThat(((AcmeUserActionRequiredException) ex).getTermsOfServiceUri().orElseThrow())
.isEqualTo(URI.create("https://example.com/tos.pdf"));
}
/**
* Test if an {@link AcmeRateLimitedException} is thrown on an acme problem.
*/
@Test
public void testAcceptThrowsRateLimitedException() {
var problem = new JSONBuilder();
problem.put("type", "urn:ietf:params:acme:error:rateLimited");
problem.put("detail", "Too many invocations");
var retryAfter = Instant.now().plusSeconds(30L).truncatedTo(SECONDS);
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_FORBIDDEN)
.withHeader("Content-Type", "application/problem+json")
.withHeader("Link", "; rel=\"help\"")
.withHeader("Retry-After", DATE_FORMATTER.format(retryAfter))
.withBody(problem.toString())
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeRateLimitedException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:rateLimited"));
assertThat(ex.getMessage()).isEqualTo("Too many invocations");
assertThat(ex.getRetryAfter().orElseThrow()).isEqualTo(retryAfter);
assertThat(ex.getDocuments()).isNotNull();
assertThat(ex.getDocuments()).hasSize(1);
assertThat(ex.getDocuments().iterator().next()).isEqualTo(url("https://example.com/rates.pdf"));
}
/**
* Test if an {@link AcmeServerException} is thrown on another problem.
*/
@Test
public void testAcceptThrowsOtherException() {
var problem = new JSONBuilder();
problem.put("type", "urn:zombie:error:apocalypse");
problem.put("detail", "Zombie apocalypse in progress");
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
.withHeader("Content-Type", "application/problem+json")
.withBody(problem.toString())
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeServerException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex.getType()).isEqualTo(URI.create("urn:zombie:error:apocalypse"));
assertThat(ex.getMessage()).isEqualTo("Zombie apocalypse in progress");
}
/**
* Test if an {@link AcmeException} is thrown if there is no error type.
*/
@Test
public void testAcceptThrowsNoTypeException() {
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
.withHeader("Content-Type", "application/problem+json")
.withBody("{}")
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeProtocolException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex.getMessage()).isNotEmpty();
}
/**
* Test if an {@link AcmeException} is thrown if there is a generic error.
*/
@Test
public void testAcceptThrowsServerException() {
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
.withStatusMessage("Infernal Server Error")
.withHeader("Content-Type", "text/html")
.withBody("Infernal Server Error ")
));
try (var nonceHolder = session.lockNonce()) {
nonceHolder.setNonce(TestUtils.DUMMY_NONCE);
}
var ex = assertThrows(AcmeException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), login);
}
});
assertThat(ex.getMessage()).isEqualTo("HTTP 500");
}
/**
* Test GET requests.
*/
@Test
public void testSendRequest() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
}
verify(getRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
}
/**
* Test GET requests with If-Modified-Since.
*/
@Test
public void testSendRequestIfModifiedSince() throws AcmeException {
var ifModifiedSince = ZonedDateTime.now(ZoneId.of("UTC")).truncatedTo(SECONDS);
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_NOT_MODIFIED))
);
try (var conn = session.connect()) {
var rc = conn.sendRequest(requestUrl, session, ifModifiedSince);
assertThat(rc).isEqualTo(HttpURLConnection.HTTP_NOT_MODIFIED);
}
verify(getRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("If-Modified-Since", equalToDateTime(ifModifiedSince))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
}
/**
* Test signed POST requests.
*/
@Test
public void testSendSignedRequest() throws Exception {
var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes());
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce1)));
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce2)
));
try (var conn = session.connect()) {
var cb = new JSONBuilder();
cb.put("foo", 123).put("bar", "a-string");
conn.sendSignedRequest(requestUrl, cb, login);
}
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isEqualTo(nonce2);
}
verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));
assertThat(requests).hasSize(1);
var data = JSON.parse(requests.get(0).getBodyAsString());
var encodedHeader = data.get("protected").asString();
var encodedSignature = data.get("signature").asString();
var encodedPayload = data.get("payload").asString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"nonce\":\"").append(nonce1).append("\",");
expectedHeader.append("\"url\":\"").append(requestUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"kid\":\"").append(accountUrl).append('"');
expectedHeader.append('}');
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8)).isEqualTo(expectedHeader.toString());
assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(login.getPublicKey());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test signed POST-as-GET requests.
*/
@Test
public void testSendSignedPostAsGetRequest() throws Exception {
var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes());
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce1)));
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce2)));
try (var conn = session.connect()) {
conn.sendSignedPostAsGetRequest(requestUrl, login);
}
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isEqualTo(nonce2);
}
verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));
assertThat(requests).hasSize(1);
var data = JSON.parse(requests.get(0).getBodyAsString());
var encodedHeader = data.get("protected").asString();
var encodedSignature = data.get("signature").asString();
var encodedPayload = data.get("payload").asString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"nonce\":\"").append(nonce1).append("\",");
expectedHeader.append("\"url\":\"").append(requestUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"kid\":\"").append(accountUrl).append('"');
expectedHeader.append('}');
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8)).isEqualTo(expectedHeader.toString());
assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEqualTo("");
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(login.getPublicKey());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test certificate POST-as-GET requests.
*/
@Test
public void testSendCertificateRequest() throws AcmeException {
var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes());
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce1)));
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce2)));
try (var conn = session.connect()) {
conn.sendCertificateRequest(requestUrl, login);
}
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isEqualTo(nonce2);
}
verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/pem-certificate-chain"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
}
/**
* Test signed POST requests without KeyIdentifier.
*/
@Test
public void testSendSignedRequestNoKid() throws Exception {
var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes());
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce1)));
stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Replay-Nonce", nonce2)));
try (var conn = session.connect()) {
var cb = new JSONBuilder();
cb.put("foo", 123).put("bar", "a-string");
conn.sendSignedRequest(requestUrl, cb, session,
(url, payload, nonce) -> JoseUtils.createJoseRequest(url, keyPair, payload, nonce, null));
}
try (var nonceHolder = session.lockNonce()) {
assertThat(nonceHolder.getNonce()).isEqualTo(nonce2);
}
verify(postRequestedFor(urlEqualTo(REQUEST_PATH))
.withHeader("Accept", equalTo("application/json"))
.withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET))
.withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE))
.withHeader("Content-Type", equalTo("application/jose+json"))
.withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN))
);
var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH)));
assertThat(requests).hasSize(1);
var data = JSON.parse(requests.get(0).getBodyAsString());
String encodedHeader = data.get("protected").asString();
String encodedSignature = data.get("signature").asString();
String encodedPayload = data.get("payload").asString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"nonce\":\"").append(nonce1).append("\",");
expectedHeader.append("\"url\":\"").append(requestUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"jwk\":{");
expectedHeader.append("\"kty\":\"").append(TestUtils.KTY).append("\",");
expectedHeader.append("\"e\":\"").append(TestUtils.E).append("\",");
expectedHeader.append("\"n\":\"").append(TestUtils.N).append("\"");
expectedHeader.append("}}");
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
.isEqualTo(expectedHeader.toString());
assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8))
.isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(login.getPublicKey());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test signed POST requests if there is no nonce.
*/
@Test
public void testSendSignedRequestNoNonce() {
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(notFound()));
assertThrows(AcmeException.class, () -> {
try (var conn = session.connect()) {
conn.sendSignedRequest(requestUrl, new JSONBuilder(), session,
(url, payload, nonce) -> JoseUtils.createJoseRequest(url, keyPair, payload, nonce, null));
}
});
}
/**
* Test getting a JSON response.
*/
@Test
public void testReadJsonResponse() throws AcmeException {
var response = new JSONBuilder();
response.put("foo", 123);
response.put("bar", "a-string");
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Content-Type", "application/json")
.withBody(response.toString())
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var result = conn.readJsonResponse();
assertThat(result).isNotNull();
assertThat(result.keySet()).hasSize(2);
assertThat(result.get("foo").asInt()).isEqualTo(123);
assertThat(result.get("bar").asString()).isEqualTo("a-string");
}
}
/**
* Test that a certificate is downloaded correctly.
*/
@Test
public void testReadCertificate() throws Exception {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Content-Type", "application/pem-certificate-chain")
.withBody(getResourceAsByteArray("/cert.pem"))
));
List downloaded;
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
downloaded = conn.readCertificates();
}
var original = TestUtils.createCertificate("/cert.pem");
assertThat(original).hasSize(2);
assertThat(downloaded).isNotNull();
assertThat(downloaded).hasSize(original.size());
for (var ix = 0; ix < downloaded.size(); ix++) {
assertThat(downloaded.get(ix).getEncoded()).isEqualTo(original.get(ix).getEncoded());
}
}
/**
* Test that a bad certificate throws an exception.
*/
@Test
public void testReadBadCertificate() throws Exception {
// Build a broken certificate chain PEM file
byte[] brokenPem;
try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) {
for (var cert : TestUtils.createCertificate("/cert.pem")) {
var badCert = cert.getEncoded();
Arrays.sort(badCert); // break it
AcmeUtils.writeToPem(badCert, AcmeUtils.PemLabel.CERTIFICATE, w);
}
w.flush();
brokenPem = baos.toByteArray();
}
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Content-Type", "application/pem-certificate-chain")
.withBody(brokenPem)
));
assertThrows(AcmeProtocolException.class, () -> {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
conn.readCertificates();
}
});
}
/**
* Test that {@link DefaultConnection#getLastModified()} returns valid dates.
*/
@Test
public void testLastModifiedUnset() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLastModified().isPresent()).isFalse();
}
}
@Test
public void testLastModifiedSet() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Last-Modified", "Thu, 07 May 2020 19:42:46 GMT")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var lm = conn.getLastModified();
assertThat(lm.isPresent()).isTrue();
assertThat(lm.get().format(DateTimeFormatter.ISO_DATE_TIME))
.isEqualTo("2020-05-07T19:42:46Z");
}
}
@Test
public void testLastModifiedInvalid() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Last-Modified", "iNvAlId")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLastModified().isPresent()).isFalse();
}
}
/**
* Test that {@link DefaultConnection#getExpiration()} returns valid dates.
*/
@Test
public void testExpirationUnset() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getExpiration().isPresent()).isFalse();
}
}
@Test
public void testExpirationNoCache() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Cache-Control", "public, no-cache")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getExpiration().isPresent()).isFalse();
}
}
@Test
public void testExpirationMaxAgeZero() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Cache-Control", "public, max-age=0, no-cache")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getExpiration().isPresent()).isFalse();
}
}
@Test
public void testExpirationMaxAgeButNoCache() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Cache-Control", "public, max-age=3600, no-cache")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getExpiration().isPresent()).isFalse();
}
}
@Test
public void testExpirationMaxAge() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Cache-Control", "max-age=3600")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var exp = conn.getExpiration();
assertThat(exp.isPresent()).isTrue();
assertThat(exp.get().isAfter(ZonedDateTime.now().plusHours(1).minusMinutes(1))).isTrue();
assertThat(exp.get().isBefore(ZonedDateTime.now().plusHours(1).plusMinutes(1))).isTrue();
}
}
@Test
public void testExpirationExpires() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Expires", "Thu, 18 Jun 2020 08:43:04 GMT")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var exp = conn.getExpiration();
assertThat(exp.isPresent()).isTrue();
assertThat(exp.get().format(DateTimeFormatter.ISO_DATE_TIME))
.isEqualTo("2020-06-18T08:43:04Z");
}
}
@Test
public void testExpirationInvalidExpires() throws AcmeException {
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Expires", "iNvAlId")
));
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getExpiration().isPresent()).isFalse();
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import java.net.URL;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Dummy implementation of {@link Connection} that always fails. Single methods are
* supposed to be overridden for testing.
*/
public class DummyConnection implements Connection {
@Override
public void resetNonce(Session session) {
throw new UnsupportedOperationException();
}
@Override
public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) {
throw new UnsupportedOperationException();
}
@Override
public int sendCertificateRequest(URL url, Login login) {
throw new UnsupportedOperationException();
}
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
throw new UnsupportedOperationException();
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login)
throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Session session, RequestSigner signer) {
throw new UnsupportedOperationException();
}
@Override
public JSON readJsonResponse() {
throw new UnsupportedOperationException();
}
@Override
public List readCertificates() {
throw new UnsupportedOperationException();
}
@Override
public Optional getRetryAfter() {
throw new UnsupportedOperationException();
}
@Override
public Optional getNonce() {
throw new UnsupportedOperationException();
}
@Override
public URL getLocation() {
throw new UnsupportedOperationException();
}
@Override
public Optional getLastModified() {
throw new UnsupportedOperationException();
}
@Override
public Optional getExpiration() {
throw new UnsupportedOperationException();
}
@Override
public Collection getLinks(String relation) {
throw new UnsupportedOperationException();
}
@Override
public void close() {
// closing is always safe
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/HttpConnectorTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link HttpConnector}.
*/
public class HttpConnectorTest {
/**
* Test if a {@link java.net.http.HttpRequest.Builder} can be created and has proper
* default values.
*/
@Test
public void testRequestBuilderDefaultValues() throws Exception {
var url = URI.create("http://example.org:123/foo").toURL();
var settings = new NetworkSettings();
var httpClient = HttpClient.newBuilder().build();
var connector = new HttpConnector(settings, httpClient);
var request = connector.createRequestBuilder(url).build();
assertThat(request.uri().toString()).isEqualTo(url.toExternalForm());
assertThat(request.timeout().orElseThrow()).isEqualTo(settings.getTimeout());
assertThat(request.headers().firstValue("User-Agent").orElseThrow())
.isEqualTo(HttpConnector.defaultUserAgent());
}
/**
* Tests that the user agent is correct.
*/
@Test
public void testUserAgent() {
var userAgent = HttpConnector.defaultUserAgent();
assertThat(userAgent).contains("acme4j/");
assertThat(userAgent).contains("Java/");
}
/**
* Test that getHttpClient returns the HttpClient passed to the constructor.
*/
@Test
public void testGetHttpClient() {
var settings = new NetworkSettings();
var httpClient = HttpClient.newBuilder().build();
var connector = new HttpConnector(settings, httpClient);
// Should return the same client instance that was passed in
assertThat(connector.getHttpClient()).isSameAs(httpClient);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/NetworkSettingsTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2019 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.connector;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.time.Duration;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link NetworkSettings}.
*/
public class NetworkSettingsTest {
/**
* Test getters and setters.
*/
@Test
public void testGettersAndSetters() {
var settings = new NetworkSettings();
var proxyAddress = new InetSocketAddress("198.51.100.1", 8080);
var proxySelector = ProxySelector.of(proxyAddress);
assertThat(settings.getProxySelector()).isSameAs(HttpClient.Builder.NO_PROXY);
settings.setProxySelector(proxySelector);
assertThat(settings.getProxySelector()).isSameAs(proxySelector);
settings.setProxySelector(null);
assertThat(settings.getProxySelector()).isEqualTo(HttpClient.Builder.NO_PROXY);
assertThat(settings.getTimeout()).isEqualTo(Duration.ofSeconds(30));
settings.setTimeout(Duration.ofMillis(5120));
assertThat(settings.getTimeout()).isEqualTo(Duration.ofMillis(5120));
var defaultAuthenticator = Authenticator.getDefault();
assertThat(settings.getAuthenticator()).isNull();
settings.setAuthenticator(defaultAuthenticator);
assertThat(settings.getAuthenticator()).isSameAs(defaultAuthenticator);
assertThat(settings.isCompressionEnabled()).isTrue();
settings.setCompressionEnabled(false);
assertThat(settings.isCompressionEnabled()).isFalse();
}
@Test
public void testInvalidTimeouts() {
var settings = new NetworkSettings();
assertThrows(IllegalArgumentException.class,
() -> settings.setTimeout(null),
"timeout accepted null");
assertThrows(IllegalArgumentException.class,
() -> settings.setTimeout(Duration.ZERO),
"timeout accepted zero duration");
assertThrows(IllegalArgumentException.class,
() -> settings.setTimeout(Duration.ofSeconds(20).negated()),
"timeout accepted negative duration");
}
@Test
public void testSystemProperty() {
assertThat(NetworkSettings.GZIP_PROPERTY_NAME)
.startsWith("org.shredzone.acme4j")
.contains("gzip");
System.clearProperty(NetworkSettings.GZIP_PROPERTY_NAME);
var settingsNone = new NetworkSettings();
assertThat(settingsNone.isCompressionEnabled()).isTrue();
System.setProperty(NetworkSettings.GZIP_PROPERTY_NAME, "true");
var settingsTrue = new NetworkSettings();
assertThat(settingsTrue.isCompressionEnabled()).isTrue();
System.setProperty(NetworkSettings.GZIP_PROPERTY_NAME, "false");
var settingsFalse = new NetworkSettings();
assertThat(settingsFalse.isCompressionEnabled()).isFalse();
System.setProperty(NetworkSettings.GZIP_PROPERTY_NAME, "1234");
var settingsNonBoolean = new NetworkSettings();
assertThat(settingsNonBoolean.isCompressionEnabled()).isFalse();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceIteratorTest.java
================================================
/*
* 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.connector;
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.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Unit test for {@link ResourceIterator}.
*/
public class ResourceIteratorTest {
private static final int PAGES = 4;
private static final int RESOURCES_PER_PAGE = 5;
private static final String TYPE = "authorizations";
private final List resourceURLs = new ArrayList<>(PAGES * RESOURCES_PER_PAGE);
private final List pageURLs = new ArrayList<>(PAGES);
@BeforeEach
public void setup() {
resourceURLs.clear();
for (var ix = 0; ix < RESOURCES_PER_PAGE * PAGES; ix++) {
resourceURLs.add(url("https://example.com/acme/auth/" + ix));
}
pageURLs.clear();
for (var ix = 0; ix < PAGES; ix++) {
pageURLs.add(url("https://example.com/acme/batch/" + ix));
}
}
/**
* Test if the {@link ResourceIterator} handles a {@code null} start URL.
*/
@Test
public void nullTest() {
assertThrows(NoSuchElementException.class, () -> {
var it = createIterator(null);
assertThat(it).isNotNull();
assertThat(it.hasNext()).isFalse();
it.next(); // throws NoSuchElementException
});
}
/**
* Test if the {@link ResourceIterator} returns all objects in the correct order.
*/
@Test
public void iteratorTest() throws IOException {
var result = new ArrayList();
var it = createIterator(pageURLs.get(0));
while (it.hasNext()) {
result.add(it.next().getLocation());
}
assertThat(result).isEqualTo(resourceURLs);
}
/**
* Test unusual {@link Iterator#next()} and {@link Iterator#hasNext()} usage.
*/
@Test
public void nextHasNextTest() throws IOException {
var result = new ArrayList();
var it = createIterator(pageURLs.get(0));
assertThat(it.hasNext()).isTrue();
assertThat(it.hasNext()).isTrue();
// don't try this at home, kids...
try {
for (;;) {
result.add(it.next().getLocation());
}
} catch (NoSuchElementException ex) {
assertThat(it.hasNext()).isFalse();
assertThat(it.hasNext()).isFalse();
}
assertThat(result).isEqualTo(resourceURLs);
}
/**
* Test that {@link Iterator#remove()} fails.
*/
@Test
public void removeTest() {
assertThrows(UnsupportedOperationException.class, () -> {
var it = createIterator(pageURLs.get(0));
it.next();
it.remove(); // throws UnsupportedOperationException
});
}
/**
* Creates a new {@link Iterator} of {@link Authorization} objects.
*
* @param first
* URL of the first page
* @return Created {@link Iterator}
*/
private Iterator createIterator(URL first) throws IOException {
var provider = new TestableConnectionProvider() {
private int ix;
@Override
public int sendSignedPostAsGetRequest(URL url, Login login) {
ix = pageURLs.indexOf(url);
assertThat(ix).isGreaterThanOrEqualTo(0);
return HttpURLConnection.HTTP_OK;
}
@Override
public JSON readJsonResponse() {
var start = ix * RESOURCES_PER_PAGE;
var end = (ix + 1) * RESOURCES_PER_PAGE;
var cb = new JSONBuilder();
cb.array(TYPE, resourceURLs.subList(start, end));
return JSON.parse(cb.toString());
}
@Override
public Collection getLinks(String relation) {
if ("next".equals(relation) && (ix + 1 < pageURLs.size())) {
return Collections.singletonList(pageURLs.get(ix + 1));
}
return Collections.emptyList();
}
};
var login = provider.createLogin();
provider.close();
return new ResourceIterator<>(login, TYPE, first, Login::bindAuthorization);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.connector;
import static org.assertj.core.api.Assertions.assertThat;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
/**
* Unit test for {@link Resource}.
*/
public class ResourceTest {
/**
* Test {@link Resource#path()}.
*/
@Test
public void testPath() {
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(Resource.NEW_NONCE.path()).isEqualTo("newNonce");
softly.assertThat(Resource.NEW_ACCOUNT.path()).isEqualTo("newAccount");
softly.assertThat(Resource.NEW_ORDER.path()).isEqualTo("newOrder");
softly.assertThat(Resource.NEW_AUTHZ.path()).isEqualTo("newAuthz");
softly.assertThat(Resource.REVOKE_CERT.path()).isEqualTo("revokeCert");
softly.assertThat(Resource.KEY_CHANGE.path()).isEqualTo("keyChange");
softly.assertThat(Resource.RENEWAL_INFO.path()).isEqualTo("renewalInfo");
});
// fails if there are untested future Resource values
assertThat(Resource.values()).hasSize(7);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java
================================================
/*
* 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.connector;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.util.ServiceLoader;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Unit tests for {@link Session#provider()}. Requires that both enclosed
* {@link AcmeProvider} implementations are registered via Java's {@link ServiceLoader}
* API when the test is run.
*/
public class SessionProviderTest {
/**
* There are no testing providers accepting {@code acme://example.org}. Test that
* connecting to this URI will result in an {@link IllegalArgumentException}.
*/
@Test
public void testNone() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new Session(new URI("acme://example.org")).provider())
.withMessage("No ACME provider found for acme://example.org");
}
/**
* Test that connecting to an acme URI will return an {@link AcmeProvider}, and that
* the result is cached.
*/
@Test
public void testConnectURI() throws Exception {
var session = new Session(new URI("acme://example.com"));
var provider = session.provider();
assertThat(provider).isInstanceOf(Provider1.class);
var provider2 = session.provider();
assertThat(provider2).isInstanceOf(Provider1.class);
assertThat(provider2).isSameAs(provider);
}
/**
* There are two testing providers accepting {@code acme://example.net}. Test that
* connecting to this URI will result in an {@link IllegalArgumentException}.
*/
@Test
public void testDuplicate() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new Session(new URI("acme://example.net")).provider())
.withMessage("Both ACME providers Provider1 and Provider2 accept" +
" acme://example.net. Please check your classpath.");
}
public static class Provider1 implements AcmeProvider {
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& ("example.com".equals(serverUri.getHost())
|| "example.net".equals(serverUri.getHost()));
}
@Override
public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
throw new UnsupportedOperationException();
}
@Override
public URL resolve(URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public JSON directory(Session session, URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public Challenge createChallenge(Login login, JSON data) {
throw new UnsupportedOperationException();
}
}
public static class Provider2 implements AcmeProvider {
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "example.net".equals(serverUri.getHost());
}
@Override
public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
throw new UnsupportedOperationException();
}
@Override
public URL resolve(URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public JSON directory(Session session, URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public Challenge createChallenge(Login login, JSON data) {
throw new UnsupportedOperationException();
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/connector/TrimmingInputStreamTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2018 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.connector;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link TrimmingInputStream}.
*/
public class TrimmingInputStreamTest {
private final static String FULL_TEXT =
"Gallia est omnis divisa in partes tres,\r\n\r\n\r\n"
+ "quarum unam incolunt Belgae, aliam Aquitani,\r\r\r\n\n"
+ "tertiam, qui ipsorum lingua Celtae, nostra Galli appellantur.";
private final static String TRIMMED_TEXT =
"Gallia est omnis divisa in partes tres,\n"
+ "quarum unam incolunt Belgae, aliam Aquitani,\n"
+ "tertiam, qui ipsorum lingua Celtae, nostra Galli appellantur.";
@Test
public void testEmpty() throws IOException {
var out = trimByStream("");
assertThat(out).isEqualTo("");
}
@Test
public void testLineBreakOnly() throws IOException {
var out1 = trimByStream("\n");
assertThat(out1).isEqualTo("");
var out2 = trimByStream("\r");
assertThat(out2).isEqualTo("");
var out3 = trimByStream("\r\n");
assertThat(out3).isEqualTo("");
}
@Test
public void testTrim() throws IOException {
var out = trimByStream(FULL_TEXT);
assertThat(out).isEqualTo(TRIMMED_TEXT);
}
@Test
public void testTrimEndOnly() throws IOException {
var out = trimByStream(FULL_TEXT + "\r\n\r\n");
assertThat(out).isEqualTo(TRIMMED_TEXT + "\n");
}
@Test
public void testTrimStartOnly() throws IOException {
var out = trimByStream("\n\n" + FULL_TEXT);
assertThat(out).isEqualTo(TRIMMED_TEXT);
}
@Test
public void testTrimFull() throws IOException {
var out = trimByStream("\n\n" + FULL_TEXT + "\r\n\r\n");
assertThat(out).isEqualTo(TRIMMED_TEXT + "\n");
}
@Test
public void testAvailable() throws IOException {
try (var in = new TrimmingInputStream(
new ByteArrayInputStream("Test".getBytes(StandardCharsets.US_ASCII)))) {
assertThat(in.available()).isNotEqualTo(0);
}
}
/**
* Trims a string by running it through the {@link TrimmingInputStream}.
*/
private String trimByStream(String str) throws IOException {
var out = new StringBuilder();
try (var in = new TrimmingInputStream(
new ByteArrayInputStream(str.getBytes(StandardCharsets.US_ASCII)))) {
int ch;
while ((ch = in.read()) >= 0) {
out.append((char) ch);
}
}
return out.toString();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeExceptionTest.java
================================================
/*
* 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 java.io.IOException;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeException}.
*/
public class AcmeExceptionTest {
@Test
public void testAcmeException() {
var ex = new AcmeException();
assertThat(ex.getMessage()).isNull();
assertThat(ex.getCause()).isNull();
}
@Test
public void testMessageAcmeException() {
var message = "Failure";
var ex = new AcmeException(message);
assertThat(ex.getMessage()).isEqualTo(message);
assertThat(ex.getCause()).isNull();
}
@Test
public void testCausedAcmeException() {
var message = "Failure";
var cause = new IOException("No network");
var ex = new AcmeException(message, cause);
assertThat(ex.getMessage()).isEqualTo(message);
assertThat(ex.getCause()).isEqualTo(cause);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeLazyLoadingExceptionTest.java
================================================
/*
* 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.mockito.Mockito.mock;
import java.io.Serial;
import java.net.URL;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.AcmeResource;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link AcmeLazyLoadingException}.
*/
public class AcmeLazyLoadingExceptionTest {
private final URL resourceUrl = TestUtils.url("http://example.com/acme/resource/123");
@Test
public void testAcmeLazyLoadingException() {
var login = mock(Login.class);
var resource = new TestResource(login, resourceUrl);
var cause = new AcmeException("Something went wrong");
var ex = new AcmeLazyLoadingException(resource, cause);
assertThat(ex).isInstanceOf(RuntimeException.class);
assertThat(ex.getMessage()).contains(resourceUrl.toString());
assertThat(ex.getMessage()).contains(TestResource.class.getSimpleName());
assertThat(ex.getCause()).isEqualTo(cause);
assertThat(ex.getType()).isEqualTo(TestResource.class);
assertThat(ex.getLocation()).isEqualTo(resourceUrl);
}
private static class TestResource extends AcmeResource {
@Serial
private static final long serialVersionUID = 1023419539450677538L;
public TestResource(Login login, URL location) {
super(login, location);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeNetworkExceptionTest.java
================================================
/*
* 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 java.io.IOException;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeNetworkException}.
*/
public class AcmeNetworkExceptionTest {
@Test
public void testAcmeNetworkException() {
var cause = new IOException("Network not reachable");
var ex = new AcmeNetworkException(cause);
assertThat(ex.getMessage()).isNotNull();
assertThat(ex.getCause()).isEqualTo(cause);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeNotSupportedExceptionTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.exception;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeNotSupportedException}.
*/
public class AcmeNotSupportedExceptionTest {
@Test
public void testAcmeNotSupportedException() {
var msg = "revoke";
var ex = new AcmeNotSupportedException(msg);
assertThat(ex).isInstanceOf(RuntimeException.class);
assertThat(ex.getMessage()).isEqualTo("Server does not support revoke");
assertThat(ex.getCause()).isNull();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeProtocolExceptionTest.java
================================================
/*
* 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 org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeProtocolException}.
*/
public class AcmeProtocolExceptionTest {
@Test
public void testAcmeProtocolException() {
var msg = "Bad content";
var ex = new AcmeProtocolException(msg);
assertThat(ex).isInstanceOf(RuntimeException.class);
assertThat(ex.getMessage()).isEqualTo(msg);
assertThat(ex.getCause()).isNull();
}
@Test
public void testCausedAcmeProtocolException() {
var message = "Bad content";
var cause = new NumberFormatException("Not a number: abc");
var ex = new AcmeProtocolException(message, cause);
assertThat(ex.getMessage()).isEqualTo(message);
assertThat(ex.getCause()).isEqualTo(cause);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeRateLimitedExceptionTest.java
================================================
/*
* 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.shredzone.acme4j.toolbox.TestUtils.createProblem;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeRateLimitedException}.
*/
public class AcmeRateLimitedExceptionTest {
/**
* Test that parameters are correctly returned.
*/
@Test
public void testAcmeRateLimitedException() {
var type = URI.create("urn:ietf:params:acme:error:rateLimited");
var detail = "Too many requests per minute";
var retryAfter = Instant.now().plus(Duration.ofMinutes(1));
var documents = Arrays.asList(
url("http://example.com/doc1.html"),
url("http://example.com/doc2.html"));
var problem = createProblem(type, detail, null);
var ex = new AcmeRateLimitedException(problem, retryAfter, documents);
assertThat(ex.getType()).isEqualTo(type);
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getRetryAfter().orElseThrow()).isEqualTo(retryAfter);
assertThat(ex.getDocuments()).containsAll(documents);
}
/**
* Test that optional parameters are null-safe.
*/
@Test
public void testNullAcmeRateLimitedException() {
var type = URI.create("urn:ietf:params:acme:error:rateLimited");
var detail = "Too many requests per minute";
var problem = createProblem(type, detail, null);
var ex = new AcmeRateLimitedException(problem, null, null);
assertThat(ex.getType()).isEqualTo(type);
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getRetryAfter()).isEmpty();
assertThat(ex.getDocuments()).isEmpty();
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/exception/AcmeUserActionRequiredExceptionTest.java
================================================
/*
* 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.shredzone.acme4j.toolbox.TestUtils.createProblem;
import java.net.MalformedURLException;
import java.net.URI;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeUserActionRequiredException}.
*/
public class AcmeUserActionRequiredExceptionTest {
/**
* Test that parameters are correctly returned.
*/
@Test
public void testAcmeUserActionRequiredException() throws MalformedURLException {
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 = URI.create("http://example.com/howToAgree.html").toURL();
var problem = createProblem(type, detail, instanceUrl);
var ex = new AcmeUserActionRequiredException(problem, tosUri);
assertThat(ex.getType()).isEqualTo(type);
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);
}
/**
* Test that optional parameters are null-safe.
*/
@Test
public void testNullAcmeUserActionRequiredException() throws MalformedURLException {
var type = URI.create("urn:ietf:params:acme:error:userActionRequired");
var detail = "Call our service";
var instanceUrl = URI.create("http://example.com/howToContactUs.html").toURL();
var problem = createProblem(type, detail, instanceUrl);
var ex = new AcmeUserActionRequiredException(problem, null);
assertThat(ex.getType()).isEqualTo(type);
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getTermsOfServiceUri()).isEmpty();
assertThat(ex.getInstance()).isEqualTo(instanceUrl);
assertThat(ex.toString()).isEqualTo("Please visit " + instanceUrl + " - details: " + detail);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/AbstractAcmeProviderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
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.DnsPersist01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.challenge.TokenChallenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.DefaultConnection;
import org.shredzone.acme4j.connector.HttpConnector;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.connector.NonceHolder;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link AbstractAcmeProvider}.
*/
public class AbstractAcmeProviderTest {
private static final URI SERVER_URI = URI.create("http://example.com/acme");
private static final URL RESOLVED_URL = TestUtils.url("http://example.com/acme/directory");
private static final NetworkSettings NETWORK_SETTINGS = new NetworkSettings();
/**
* Test that connect returns a connection.
*/
@Test
public void testConnect() {
var invoked = new AtomicBoolean();
var httpClient = HttpClient.newBuilder().build();
var provider = new TestAbstractAcmeProvider() {
@Override
protected HttpConnector createHttpConnector(NetworkSettings settings, HttpClient client) {
assertThat(settings).isSameAs(NETWORK_SETTINGS);
assertThat(client).isSameAs(httpClient);
invoked.set(true);
return super.createHttpConnector(settings, client);
}
};
var connection = provider.connect(SERVER_URI, NETWORK_SETTINGS, httpClient);
assertThat(connection).isNotNull();
assertThat(connection).isInstanceOf(DefaultConnection.class);
assertThat(invoked).isTrue();
}
/**
* Verify that the resources directory is read.
*/
@Test
public void testResources() throws AcmeException {
var connection = mock(Connection.class);
var session = mock(Session.class);
when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
when(session.lockNonce()).thenReturn(mock(NonceHolder.class));
var provider = new TestAbstractAcmeProvider(connection);
var map = provider.directory(session, SERVER_URI);
assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
verify(connection).sendRequest(RESOLVED_URL, session, null);
verify(connection).getNonce();
verify(connection).getLastModified();
verify(connection).getExpiration();
verify(connection).readJsonResponse();
verify(connection).close();
verifyNoMoreInteractions(connection);
}
/**
* Verify that the cache control headers are evaluated.
*/
@Test
public void testResourcesCacheControl() throws AcmeException {
var lastModified = ZonedDateTime.now().minus(13, ChronoUnit.DAYS);
var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
var connection = mock(Connection.class);
var session = mock(Session.class);
when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
when(connection.getLastModified()).thenReturn(Optional.of(lastModified));
when(connection.getExpiration()).thenReturn(Optional.of(expiryDate));
when(session.lockNonce()).thenReturn(mock(NonceHolder.class));
when(session.getDirectoryExpires()).thenReturn(null);
when(session.getDirectoryLastModified()).thenReturn(null);
when(session.networkSettings()).thenReturn(NETWORK_SETTINGS);
when(session.getHttpClient()).thenReturn(HttpClient.newBuilder().build());
var provider = new TestAbstractAcmeProvider(connection);
var map = provider.directory(session, SERVER_URI);
assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
verify(session).setDirectoryLastModified(eq(lastModified));
verify(session).setDirectoryExpires(eq(expiryDate));
verify(session).getDirectoryExpires();
verify(session).getDirectoryLastModified();
verify(session).networkSettings();
verify(session).getHttpClient();
verify(session).lockNonce();
verifyNoMoreInteractions(session);
verify(connection).sendRequest(RESOLVED_URL, session, null);
verify(connection).getNonce();
verify(connection).getLastModified();
verify(connection).getExpiration();
verify(connection).readJsonResponse();
verify(connection).close();
verifyNoMoreInteractions(connection);
}
/**
* Verify that resorces are not fetched if not yet expired.
*/
@Test
public void testResourcesNotExprired() throws AcmeException {
var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
var connection = mock(Connection.class);
var session = mock(Session.class);
when(session.getDirectoryExpires()).thenReturn(expiryDate);
var provider = new TestAbstractAcmeProvider();
var map = provider.directory(session, SERVER_URI);
assertThat(map).isNull();
verify(session).getDirectoryExpires();
verifyNoMoreInteractions(session);
verifyNoMoreInteractions(connection);
}
/**
* Verify that resorces are fetched if expired.
*/
@Test
public void testResourcesExprired() throws AcmeException {
var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
var pastExpiryDate = ZonedDateTime.now().minus(10, ChronoUnit.MINUTES);
var connection = mock(Connection.class);
var session = mock(Session.class);
when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
when(connection.getExpiration()).thenReturn(Optional.of(expiryDate));
when(connection.getLastModified()).thenReturn(Optional.empty());
when(session.getDirectoryExpires()).thenReturn(pastExpiryDate);
when(session.networkSettings()).thenReturn(NETWORK_SETTINGS);
when(session.getHttpClient()).thenReturn(HttpClient.newBuilder().build());
when(session.lockNonce()).thenReturn(mock(NonceHolder.class));
var provider = new TestAbstractAcmeProvider(connection);
var map = provider.directory(session, SERVER_URI);
assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
verify(session).setDirectoryExpires(eq(expiryDate));
verify(session).setDirectoryLastModified(eq(null));
verify(session).getDirectoryExpires();
verify(session).getDirectoryLastModified();
verify(session).networkSettings();
verify(session).getHttpClient();
verify(session).lockNonce();
verifyNoMoreInteractions(session);
verify(connection).sendRequest(RESOLVED_URL, session, null);
verify(connection).getNonce();
verify(connection).getLastModified();
verify(connection).getExpiration();
verify(connection).readJsonResponse();
verify(connection).close();
verifyNoMoreInteractions(connection);
}
/**
* Verify that if-modified-since is used.
*/
@Test
public void testResourcesIfModifiedSince() throws AcmeException {
var modifiedSinceDate = ZonedDateTime.now().minus(60, ChronoUnit.DAYS);
var connection = mock(Connection.class);
var session = mock(Session.class);
when(connection.sendRequest(eq(RESOLVED_URL), eq(session), eq(modifiedSinceDate)))
.thenReturn(HttpURLConnection.HTTP_NOT_MODIFIED);
when(connection.getLastModified()).thenReturn(Optional.of(modifiedSinceDate));
when(session.getDirectoryLastModified()).thenReturn(modifiedSinceDate);
when(session.networkSettings()).thenReturn(NETWORK_SETTINGS);
when(session.getHttpClient()).thenReturn(HttpClient.newBuilder().build());
when(session.lockNonce()).thenReturn(mock(NonceHolder.class));
var provider = new TestAbstractAcmeProvider(connection);
var map = provider.directory(session, SERVER_URI);
assertThat(map).isNull();
verify(session).getDirectoryExpires();
verify(session).getDirectoryLastModified();
verify(session).networkSettings();
verify(session).getHttpClient();
verify(session).lockNonce();
verifyNoMoreInteractions(session);
verify(connection).sendRequest(RESOLVED_URL, session, modifiedSinceDate);
verify(connection).close();
verifyNoMoreInteractions(connection);
}
/**
* Test that challenges are generated properly.
*/
@Test
public void testCreateChallenge() {
var login = TestUtils.login();
var provider = new TestAbstractAcmeProvider();
var c1 = provider.createChallenge(login, getJSON("httpChallenge"));
assertThat(c1).isNotNull();
assertThat(c1).isInstanceOf(Http01Challenge.class);
var c2 = provider.createChallenge(login, getJSON("httpChallenge"));
assertThat(c2).isNotSameAs(c1);
var c3 = provider.createChallenge(login, getJSON("dns01Challenge"));
assertThat(c3).isNotNull();
assertThat(c3).isInstanceOf(Dns01Challenge.class);
var c4 = provider.createChallenge(login, getJSON("dnsAccount01Challenge"));
assertThat(c4).isNotNull();
assertThat(c4).isInstanceOf(DnsAccount01Challenge.class);
var c8 = provider.createChallenge(login, getJSON("dnsPersist01Challenge"));
assertThat(c8).isNotNull();
assertThat(c8).isInstanceOf(DnsPersist01Challenge.class);
var c5 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
assertThat(c5).isNotNull();
assertThat(c5).isInstanceOf(TlsAlpn01Challenge.class);
var json6 = new JSONBuilder()
.put("type", "foobar-01")
.put("url", "https://example.com/some/challenge")
.toJSON();
var c6 = provider.createChallenge(login, json6);
assertThat(c6).isNotNull();
assertThat(c6).isInstanceOf(Challenge.class);
var json7 = new JSONBuilder()
.put("type", "foobar-01")
.put("token", "abc123")
.put("url", "https://example.com/some/challenge")
.toJSON();
var c7 = provider.createChallenge(login, json7);
assertThat(c7).isNotNull();
assertThat(c7).isInstanceOf(TokenChallenge.class);
assertThrows(AcmeProtocolException.class, () -> {
var json8 = new JSONBuilder()
.put("url", "https://example.com/some/challenge")
.toJSON();
provider.createChallenge(login, json8);
});
assertThrows(NullPointerException.class, () -> provider.createChallenge(login, null));
}
private static class TestAbstractAcmeProvider extends AbstractAcmeProvider {
private final Connection connection;
public TestAbstractAcmeProvider() {
this.connection = null;
}
public TestAbstractAcmeProvider(Connection connection) {
this.connection = connection;
}
@Override
public boolean accepts(URI serverUri) {
assertThat(serverUri).isEqualTo(SERVER_URI);
return true;
}
@Override
public URL resolve(URI serverUri) {
assertThat(serverUri).isEqualTo(SERVER_URI);
return RESOLVED_URL;
}
@Override
public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
assertThat(serverUri).isEqualTo(SERVER_URI);
return connection != null ? connection : super.connect(serverUri, networkSettings, httpClient);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/GenericAcmeProviderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.shredzone.acme4j.toolbox.TestUtils.DEFAULT_NETWORK_SETTINGS;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.connector.DefaultConnection;
/**
* Unit tests for {@link GenericAcmeProvider}.
*/
public class GenericAcmeProviderTest {
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new GenericAcmeProvider();
assertThat(provider.accepts(new URI("http://example.com/acme"))).isTrue();
assertThat(provider.accepts(new URI("https://example.com/acme"))).isTrue();
assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
}
/**
* Test if the provider resolves the URI correctly.
*/
@Test
public void testResolve() throws URISyntaxException {
var serverUri = new URI("http://example.com/acme?foo=abc&bar=123");
var provider = new GenericAcmeProvider();
var resolvedUrl = provider.resolve(serverUri);
assertThat(resolvedUrl.toString()).isEqualTo(serverUri.toString());
var httpClient = HttpClient.newBuilder().build();
var connection = provider.connect(serverUri, DEFAULT_NETWORK_SETTINGS, httpClient);
assertThat(connection).isInstanceOf(DefaultConnection.class);
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java
================================================
/*
* 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.provider;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.security.KeyPair;
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;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.DummyConnection;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Test implementation of {@link AcmeProvider}. It also implements a dummy implementation
* of {@link Connection} that is always returned on {@link #connect(URI, NetworkSettings)}.
*/
public class TestableConnectionProvider extends DummyConnection implements AcmeProvider {
private final Map> creatorMap = new HashMap<>();
private final Map createdMap = new HashMap<>();
private final JSONBuilder directory = new JSONBuilder();
private final KeyPair keyPair = TestUtils.createKeyPair();
private JSONBuilder metadata = null;
/**
* Register a {@link Resource} mapping.
*
* @param r
* {@link Resource} to be mapped
* @param u
* {@link URL} to be returned
*/
public void putTestResource(Resource r, URL u) {
directory.put(r.path(), u);
}
/**
* Add a property to the metadata registry.
*
* @param key
* Metadata key
* @param value
* Metadata value
*/
public void putMetadata(String key, Object value) {
if (metadata == null) {
metadata = directory.object("meta");
}
metadata.put(key, value);
}
/**
* Register a {@link Challenge}.
*
* @param type
* Challenge type to register.
* @param creator
* Creator {@link BiFunction} that creates a matching {@link Challenge}
*/
public void putTestChallenge(String type, BiFunction creator) {
creatorMap.put(type, creator);
}
/**
* Returns the {@link Challenge} instance that has been created. Fails if no such
* challenge was created.
*
* @param type Challenge type
* @return Created {@link Challenge} instance
*/
public Challenge getChallenge(String type) {
if (!createdMap.containsKey(type)) {
throw new IllegalArgumentException("No challenge of type " + type + " was created");
}
return createdMap.get(type);
}
/**
* Creates a {@link Session} that uses this {@link AcmeProvider}.
*/
public Session createSession() {
return TestUtils.session(this);
}
/**
* Creates a {@link Login} that uses this {@link AcmeProvider}.
*/
public Login createLogin() throws IOException {
var session = createSession();
return session.login(URI.create(TestUtils.ACCOUNT_URL).toURL(), keyPair);
}
public KeyPair getAccountKeyPair() {
return keyPair;
}
@Override
public Optional getRetryAfter() {
return Optional.empty();
}
@Override
public boolean accepts(URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public URL resolve(URI serverUri) {
throw new UnsupportedOperationException();
}
@Override
public Connection connect(URI serverUri, NetworkSettings networkSettings, HttpClient httpClient) {
return this;
}
@Override
public JSON directory(Session session, URI serverUri) {
if (directory.toMap().isEmpty()) {
throw new UnsupportedOperationException();
}
return directory.toJSON();
}
@Override
public Challenge createChallenge(Login login, JSON data) {
if (creatorMap.isEmpty()) {
throw new UnsupportedOperationException();
}
Challenge created;
var type = data.get("type").asString();
if (creatorMap.containsKey(type)) {
created = creatorMap.get(type).apply(login, data);
} else {
created = new Challenge(login, data);
}
createdMap.put(type, created);
return created;
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/actalis/ActalisAcmeProviderTest.java
================================================
/*
* 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")));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/google/GoogleAcmeProviderTest.java
================================================
/*
* 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");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProviderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.letsencrypt;
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 LetsEncryptAcmeProvider}.
*/
public class LetsEncryptAcmeProviderTest {
private static final String V02_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory";
private static final String STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new LetsEncryptAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://letsencrypt.org"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://letsencrypt.org/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://letsencrypt.org/staging"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://letsencrypt.org/v02"))).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 LetsEncryptAcmeProvider();
assertThat(provider.resolve(new URI("acme://letsencrypt.org"))).isEqualTo(url(V02_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://letsencrypt.org/"))).isEqualTo(url(V02_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://letsencrypt.org/v02"))).isEqualTo(url(V02_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://letsencrypt.org/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL));
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://letsencrypt.org/v99")));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/pebble/PebbleAcmeProviderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2017 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.pebble;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.connector.NetworkSettings;
/**
* Unit tests for {@link PebbleAcmeProvider}.
*/
public class PebbleAcmeProviderTest {
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new PebbleAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://pebble"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pebble/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pebble:12345"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pebble:12345/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pebble/some-host.example.com"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pebble/some-host.example.com:12345"))).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 PebbleAcmeProvider();
assertThat(provider.resolve(new URI("acme://pebble")))
.isEqualTo(url("https://localhost:14000/dir"));
assertThat(provider.resolve(new URI("acme://pebble/")))
.isEqualTo(url("https://localhost:14000/dir"));
assertThat(provider.resolve(new URI("acme://pebble:12345")))
.isEqualTo(url("https://localhost:12345/dir"));
assertThat(provider.resolve(new URI("acme://pebble:12345/")))
.isEqualTo(url("https://localhost:12345/dir"));
assertThat(provider.resolve(new URI("acme://pebble/pebble.example.com")))
.isEqualTo(url("https://pebble.example.com:14000/dir"));
assertThat(provider.resolve(new URI("acme://pebble/pebble.example.com:12345")))
.isEqualTo(url("https://pebble.example.com:12345/dir"));
assertThat(provider.resolve(new URI("acme://pebble/pebble.example.com:12345/")))
.isEqualTo(url("https://pebble.example.com:12345/dir"));
assertThrows(IllegalArgumentException.class, () ->
provider.resolve(new URI("acme://pebble/bad.example.com:port")));
assertThrows(IllegalArgumentException.class, () ->
provider.resolve(new URI("acme://pebble/bad.example.com:1234/foo")));
}
/**
* Test that createPebbleTrustManagerFactory creates a TrustManagerFactory
* with the Pebble certificate loaded from the PEM file.
*/
@Test
public void testCreatePebbleTrustManagerFactory() throws Exception {
var provider = new PebbleAcmeProvider();
// Create the TrustManagerFactory
TrustManagerFactory tmf = provider.createPebbleTrustManagerFactory();
assertThat(tmf).isNotNull();
// Get the trust managers
javax.net.ssl.TrustManager[] trustManagers = tmf.getTrustManagers();
assertThat(trustManagers.length).isGreaterThan(0);
// Find an X509TrustManager
X509TrustManager x509TrustManager = null;
for (javax.net.ssl.TrustManager tm : trustManagers) {
if (tm instanceof X509TrustManager) {
x509TrustManager = (X509TrustManager) tm;
break;
}
}
assertThat(x509TrustManager).isNotNull();
// Verify the Pebble certificate is in the accepted issuers
X509Certificate[] acceptedIssuers = x509TrustManager.getAcceptedIssuers();
assertThat(acceptedIssuers.length).isGreaterThan(0);
// Load the Pebble certificate from the resource to compare
X509Certificate pebbleCert = loadPebbleCertificate();
assertThat(pebbleCert).isNotNull();
// Verify that the Pebble certificate is in the accepted issuers
boolean foundPebbleCert = false;
for (X509Certificate cert : acceptedIssuers) {
if (cert.getSerialNumber().equals(pebbleCert.getSerialNumber()) &&
cert.getIssuerDN().equals(pebbleCert.getIssuerDN())) {
foundPebbleCert = true;
break;
}
}
// Verify the Pebble certificate is present in the trust store
assertThat(foundPebbleCert)
.as("Pebble certificate should be present in the TrustManagerFactory")
.isTrue();
}
/**
* Test that createHttpClient creates an HttpClient with Pebble SSL context
* and verifies it calls createPebbleTrustManagerFactory when creating the SSL context.
*/
@Test
public void testCreateHttpClient() throws Exception {
var provider = spy(new PebbleAcmeProvider());
var settings = new NetworkSettings();
var httpClient = provider.createHttpClient(settings);
assertThat(httpClient).isNotNull();
assertThat(httpClient.followRedirects()).isEqualTo(HttpClient.Redirect.NORMAL);
assertThat(httpClient.connectTimeout().orElseThrow()).isEqualTo(settings.getTimeout());
// Verify that createPebbleTrustManagerFactory was called exactly once
// (it's called when creating the SSL context, which happens once per createHttpClient call)
verify(provider).createPebbleTrustManagerFactory();
// Verify that the SSL context is configured (not null)
var sslContext = httpClient.sslContext();
assertThat(sslContext).isNotNull();
// Verify Pebble-specific SSL context properties
// These properties confirm that the SSL context was created using createPebbleTrustManagerFactory
assertThat(sslContext.getProtocol()).isEqualTo("TLS");
// Verify the SSL context is properly initialized
assertThat(sslContext.getProvider()).isNotNull();
}
/**
* Loads the Pebble certificate from the resource file.
* This matches how PebbleAcmeProvider loads it.
*/
private X509Certificate loadPebbleCertificate() throws Exception {
// Try the same resource paths as PebbleAcmeProvider
String[] resourcePaths = {
"/pebble.minica.pem",
"/META-INF/pebble.minica.pem",
"/org/shredzone/acme4j/provider/pebble/pebble.minica.pem"
};
for (String resourcePath : resourcePaths) {
try (InputStream in = PebbleAcmeProvider.class.getResourceAsStream(resourcePath)) {
if (in != null) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(in);
}
}
}
throw new AssertionError("Could not find Pebble certificate resource");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProviderTest.java
================================================
/*
* 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")));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/provider/zerossl/ZeroSSLAcmeProviderTest.java
================================================
/*
* 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")));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/AcmeUtilsTest.java
================================================
/*
* 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.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;
import static org.shredzone.acme4j.toolbox.AcmeUtils.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.reflect.Modifier;
import java.net.URI;
import java.security.Security;
import java.security.cert.CertificateEncodingException;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.stream.Stream;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
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;
import org.shredzone.acme4j.exception.AcmeProtocolException;
/**
* Unit tests for {@link AcmeUtils}.
*/
public class AcmeUtilsTest {
@BeforeAll
public static void setup() {
Security.addProvider(new BouncyCastleProvider());
}
/**
* Test that constructor is private.
*/
@Test
public void testPrivateConstructor() throws Exception {
var constructor = AcmeUtils.class.getDeclaredConstructor();
assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue();
constructor.setAccessible(true);
constructor.newInstance();
}
/**
* Test sha-256 hash and hex encode.
*/
@Test
public void testSha256HashHexEncode() {
var hexEncode = hexEncode(sha256hash("foobar"));
assertThat(hexEncode).isEqualTo("c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2");
}
/**
* Test base64 URL encode.
*/
@Test
public void testBase64UrlEncode() {
var base64UrlEncode = base64UrlEncode(sha256hash("foobar"));
assertThat(base64UrlEncode).isEqualTo("w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI");
}
/**
* Test base64 URL decode.
*/
@Test
public void testBase64UrlDecode() {
var base64UrlDecode = base64UrlDecode("w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI");
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
*/
@ParameterizedTest
@ValueSource(strings = {
"",
"Zg",
"Zm9v",
})
public void testBase64UrlValid(String url) {
assertThat(isValidBase64Url(url)).isTrue();
}
/**
* Test base64 URL validation for invalid values
*/
@ParameterizedTest
@ValueSource(strings = {
" ",
"Zg=",
"Zg==",
" Zm9v ",
".illegal#Text",
})
@NullSource
public void testBase64UrlInvalid(String url) {
assertThat(isValidBase64Url(url)).isFalse();
}
/**
* Test ACE conversion.
*/
@Test
public void testToAce() {
// Test ASCII domains in different notations
assertThat(toAce("example.com")).isEqualTo("example.com");
assertThat(toAce(" example.com ")).isEqualTo("example.com");
assertThat(toAce("ExAmPlE.CoM")).isEqualTo("example.com");
assertThat(toAce("foo.example.com")).isEqualTo("foo.example.com");
assertThat(toAce("bar.foo.example.com")).isEqualTo("bar.foo.example.com");
// Test IDN domains
assertThat(toAce("ExÄmþle.¢öM")).isEqualTo("xn--exmle-hra7p.xn--m-7ba6w");
// Test alternate separators
assertThat(toAce("example\u3002com")).isEqualTo("example.com");
assertThat(toAce("example\uff0ecom")).isEqualTo("example.com");
assertThat(toAce("example\uff61com")).isEqualTo("example.com");
// Test ACE encoded domains, they must not change
assertThat(toAce("xn--exmle-hra7p.xn--m-7ba6w"))
.isEqualTo("xn--exmle-hra7p.xn--m-7ba6w");
}
/**
* Test valid strings.
*/
@ParameterizedTest
@MethodSource("provideTimestamps")
public void testParser(String input, String expected) {
Arguments.of(input, expected, within(1, ChronoUnit.MILLIS));
}
private static Stream provideTimestamps() {
return Stream.of(
Arguments.of("2015-12-27T22:58:35.006769519Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.00676951Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.0067695Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.006769Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.00676Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.0067Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.006Z", "2015-12-27T22:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.01Z", "2015-12-27T22:58:35.010Z"),
Arguments.of("2015-12-27T22:58:35.2Z", "2015-12-27T22:58:35.200Z"),
Arguments.of("2015-12-27T22:58:35Z", "2015-12-27T22:58:35.000Z"),
Arguments.of("2015-12-27t22:58:35z", "2015-12-27T22:58:35.000Z"),
Arguments.of("2015-12-27T22:58:35.006769519+02:00", "2015-12-27T20:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.006+02:00", "2015-12-27T20:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35+02:00", "2015-12-27T20:58:35.000Z"),
Arguments.of("2015-12-27T21:58:35.006769519-02:00", "2015-12-27T23:58:35.006Z"),
Arguments.of("2015-12-27T21:58:35.006-02:00", "2015-12-27T23:58:35.006Z"),
Arguments.of("2015-12-27T21:58:35-02:00", "2015-12-27T23:58:35.000Z"),
Arguments.of("2015-12-27T22:58:35.006769519+0200", "2015-12-27T20:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35.006+0200", "2015-12-27T20:58:35.006Z"),
Arguments.of("2015-12-27T22:58:35+0200", "2015-12-27T20:58:35.000Z"),
Arguments.of("2015-12-27T21:58:35.006769519-0200", "2015-12-27T23:58:35.006Z"),
Arguments.of("2015-12-27T21:58:35.006-0200", "2015-12-27T23:58:35.006Z"),
Arguments.of("2015-12-27T21:58:35-0200", "2015-12-27T23:58:35.000Z")
);
}
/**
* Test invalid strings.
*/
@Test
public void testInvalid() {
assertThrows(IllegalArgumentException.class,
() -> parseTimestamp(""),
"accepted empty string");
assertThrows(IllegalArgumentException.class,
() -> parseTimestamp("abc"),
"accepted nonsense string");
assertThrows(IllegalArgumentException.class,
() -> parseTimestamp("2015-12-27"),
"accepted date only string");
assertThrows(IllegalArgumentException.class,
() -> parseTimestamp("2015-12-27T"),
"accepted string without time");
}
/**
* Test that locales are correctly converted to language headers.
*/
@Test
public void testLocaleToLanguageHeader() {
assertThat(localeToLanguageHeader(Locale.ENGLISH))
.isEqualTo("en,*;q=0.1");
assertThat(localeToLanguageHeader(new Locale("en", "US")))
.isEqualTo("en-US,en;q=0.8,*;q=0.1");
assertThat(localeToLanguageHeader(Locale.GERMAN))
.isEqualTo("de,*;q=0.1");
assertThat(localeToLanguageHeader(Locale.GERMANY))
.isEqualTo("de-DE,de;q=0.8,*;q=0.1");
assertThat(localeToLanguageHeader(new Locale("")))
.isEqualTo("*");
assertThat(localeToLanguageHeader(null))
.isEqualTo("*");
}
/**
* Test that error prefix is correctly removed.
*/
@Test
public void testStripErrorPrefix() {
assertThat(stripErrorPrefix("urn:ietf:params:acme:error:unauthorized")).isEqualTo("unauthorized");
assertThat(stripErrorPrefix("urn:somethingelse:error:message")).isNull();
assertThat(stripErrorPrefix(null)).isNull();
}
/**
* Test that {@link AcmeUtils#writeToPem(byte[], PemLabel, Writer)} writes a correct PEM
* file.
*/
@Test
public void testWriteToPem() throws IOException, CertificateEncodingException {
var certChain = TestUtils.createCertificate("/cert.pem");
var pemFile = new ByteArrayOutputStream();
try (var w = new OutputStreamWriter(pemFile)) {
for (var cert : certChain) {
AcmeUtils.writeToPem(cert.getEncoded(), AcmeUtils.PemLabel.CERTIFICATE, w);
}
}
var originalFile = new ByteArrayOutputStream();
try (var in = getClass().getResourceAsStream("/cert.pem")) {
var buffer = new byte[2048];
int len;
while ((len = in.read(buffer)) >= 0) {
originalFile.write(buffer, 0, len);
}
}
assertThat(pemFile.toByteArray()).isEqualTo(originalFile.toByteArray());
}
/**
* Test {@link AcmeUtils#getContentType(String)} for JSON types.
*/
@ParameterizedTest
@ValueSource(strings = {
"application/json",
"application/json; charset=utf-8",
"application/json; charset=utf-8 (Plain text)",
"application/json; charset=\"utf-8\"",
"application/json; charset=\"UTF-8\"; foo=4",
" application/json ;foo=4",
})
public void testGetContentTypeForJson(String contentType) {
assertThat(AcmeUtils.getContentType(contentType)).isEqualTo("application/json");
}
/**
* Test {@link AcmeUtils#getContentType(String)} with other types.
*/
@Test
public void testGetContentType() {
assertThat(AcmeUtils.getContentType(null)).isNull();
assertThat(AcmeUtils.getContentType("Application/Problem+JSON"))
.isEqualTo("application/problem+json");
assertThrows(AcmeProtocolException.class,
() -> AcmeUtils.getContentType("application/json; charset=\"iso-8859-1\""));
}
/**
* Test that {@link AcmeUtils#validateContact(java.net.URI)} refuses invalid
* contacts.
*/
@Test
public void testValidateContact() {
AcmeUtils.validateContact(URI.create("mailto:foo@example.com"));
assertThrows(IllegalArgumentException.class,
() -> AcmeUtils.validateContact(URI.create("mailto:foo@example.com,bar@example.com")),
"multiple recipients are accepted");
assertThrows(IllegalArgumentException.class,
() -> AcmeUtils.validateContact(URI.create("mailto:foo@example.com?to=bar@example.com")),
"hfields are accepted");
assertThrows(IllegalArgumentException.class,
() -> AcmeUtils.validateContact(URI.create("mailto:?to=foo@example.com")),
"only hfields are accepted");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONBuilderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.toolbox;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import org.jose4j.json.JsonUtil;
import org.jose4j.lang.JoseException;
import org.junit.jupiter.api.Test;
/**
* Unit test for {@link JSONBuilder}.
*/
public class JSONBuilderTest {
/**
* Test that an empty JSON builder is empty.
*/
@Test
public void testEmpty() {
var cb = new JSONBuilder();
assertThat(cb.toString()).isEqualTo("{}");
}
/**
* Test basic data types. Also test that methods return {@code this}, and that
* existing keys are replaced.
*/
@Test
public void testBasics() {
JSONBuilder res;
var cb = new JSONBuilder();
res = cb.put("fooStr", "String");
assertThat(res).isSameAs(cb);
res = cb.put("fooInt", 123);
assertThat(res).isSameAs(cb);
res = cb.put("fooInt", 456);
assertThat(res).isSameAs(cb);
assertThat(cb.toString()).isEqualTo("{\"fooStr\":\"String\",\"fooInt\":456}");
var map = cb.toMap();
assertThat(map.keySet()).hasSize(2);
assertThat(map).extracting("fooInt").isEqualTo(456);
assertThat(map).extracting("fooStr").isEqualTo("String");
var json = cb.toJSON();
assertThat(json.keySet()).hasSize(2);
assertThat(json.get("fooInt").asInt()).isEqualTo(456);
assertThat(json.get("fooStr").asString()).isEqualTo("String");
}
/**
* Test date type.
*/
@Test
public void testDate() {
var date = ZonedDateTime.of(2016, 6, 1, 5, 13, 46, 0, ZoneId.of("GMT+2")).toInstant();
var duration = Duration.ofMinutes(5);
var cb = new JSONBuilder();
cb.put("fooDate", date);
cb.put("fooDuration", duration);
cb.put("fooNull", (Object) null);
assertThat(cb.toString()).isEqualTo("{\"fooDate\":\"2016-06-01T03:13:46Z\",\"fooDuration\":300,\"fooNull\":null}");
}
/**
* Test base64 encoding.
*/
@Test
public void testBase64() {
var data = "abc123".getBytes();
JSONBuilder res;
var cb = new JSONBuilder();
res = cb.putBase64("foo", data);
assertThat(res).isSameAs(cb);
assertThat(cb.toString()).isEqualTo("{\"foo\":\"YWJjMTIz\"}");
}
/**
* Test JWK.
*/
@Test
public void testKey() throws IOException, JoseException {
var keyPair = TestUtils.createKeyPair();
JSONBuilder res;
var cb = new JSONBuilder();
res = cb.putKey("foo", keyPair.getPublic());
assertThat(res).isSameAs(cb);
var json = JsonUtil.parseJson(cb.toString());
assertThat(json).containsKey("foo");
var jwk = (Map) json.get("foo");
assertThat(jwk.keySet()).hasSize(3);
assertThat(jwk).extracting("n").isEqualTo(TestUtils.N);
assertThat(jwk).extracting("e").isEqualTo(TestUtils.E);
assertThat(jwk).extracting("kty").isEqualTo(TestUtils.KTY);
}
/**
* Test sub claims (objects).
*/
@Test
public void testObject() {
var cb = new JSONBuilder();
var sub = cb.object("sub");
assertThat(sub).isNotSameAs(cb);
assertThat(cb.toString()).isEqualTo("{\"sub\":{}}");
cb.put("foo", 123);
sub.put("foo", 456);
assertThat(cb.toString()).isEqualTo("{\"sub\":{\"foo\":456},\"foo\":123}");
}
/**
* Test arrays.
*/
@Test
public void testArray() {
JSONBuilder res;
var cb1 = new JSONBuilder();
res = cb1.array("ar", Collections.emptyList());
assertThat(res).isSameAs(cb1);
assertThat(cb1.toString()).isEqualTo("{\"ar\":[]}");
var cb2 = new JSONBuilder();
res = cb2.array("ar", Collections.singletonList(123));
assertThat(res).isSameAs(cb2);
assertThat(cb2.toString()).isEqualTo("{\"ar\":[123]}");
var cb3 = new JSONBuilder();
res = cb3.array("ar", Arrays.asList(123, "foo", 456));
assertThat(res).isSameAs(cb3);
assertThat(cb3.toString()).isEqualTo("{\"ar\":[123,\"foo\",456]}");
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JSONTest.java
================================================
/*
* 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.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
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.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.URI;
import java.net.URL;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON.Value;
/**
* Unit test for {@link JSON}.
*/
public class JSONTest {
private static final URL BASE_URL = url("https://example.com/acme/1");
/**
* Test that an empty {@link JSON} is empty.
*/
@Test
public void testEmpty() {
var empty = JSON.empty();
assertThat(empty.toString()).isEqualTo("{}");
assertThat(empty.toMap().keySet()).isEmpty();
}
/**
* Test parsers.
*/
@Test
public void testParsers() throws IOException {
String json = "{\"foo\":\"a-text\",\n\"bar\":123}";
var fromString = JSON.parse(json);
assertThatJson(fromString.toString()).isEqualTo(json);
var map = fromString.toMap();
assertThat(map).hasSize(2);
assertThat(map.keySet()).containsExactlyInAnyOrder("foo", "bar");
assertThat(map.get("foo")).isEqualTo("a-text");
assertThat(map.get("bar")).isEqualTo(123L);
try (var in = new ByteArrayInputStream(json.getBytes(UTF_8))) {
var fromStream = JSON.parse(in);
assertThatJson(fromStream.toString()).isEqualTo(json);
var map2 = fromStream.toMap();
assertThat(map2).hasSize(2);
assertThat(map2.keySet()).containsExactlyInAnyOrder("foo", "bar");
assertThat(map2.get("foo")).isEqualTo("a-text");
assertThat(map2.get("bar")).isEqualTo(123L);
}
}
/**
* Test that bad JSON fails.
*/
@Test
public void testParsersBadJSON() {
assertThrows(AcmeProtocolException.class,
() -> JSON.parse("This is no JSON.")
);
}
/**
* Test all object related methods.
*/
@Test
public void testObject() {
var json = TestUtils.getJSON("datatypes");
assertThat(json.keySet()).containsExactlyInAnyOrder(
"text", "number", "boolean", "uri", "url", "date", "array",
"collect", "status", "binary", "duration", "problem", "encoded");
assertThat(json.contains("text")).isTrue();
assertThat(json.contains("music")).isFalse();
assertThat(json.get("text")).isNotNull();
assertThat(json.get("music")).isNotNull();
}
/**
* Test all array related methods.
*/
@Test
public void testArray() {
var json = TestUtils.getJSON("datatypes");
var array = json.get("array").asArray();
assertThat(array.isEmpty()).isFalse();
assertThat(array).hasSize(4).doesNotContainNull();
}
/**
* Test empty array.
*/
@Test
public void testEmptyArray() {
var json = TestUtils.getJSON("datatypes");
var array = json.get("missingArray").asArray();
assertThat(array.isEmpty()).isTrue();
assertThat(array).hasSize(0);
assertThat(array.stream().count()).isEqualTo(0L);
}
/**
* Test all array iterator related methods.
*/
@Test
public void testArrayIterator() {
var json = TestUtils.getJSON("datatypes");
var array = json.get("array").asArray();
var it = array.iterator();
assertThat(it).isNotNull();
assertThat(it.hasNext()).isTrue();
assertThat(it.next().asString()).isEqualTo("foo");
assertThat(it.hasNext()).isTrue();
assertThat(it.next().asInt()).isEqualTo(987);
assertThat(it.hasNext()).isTrue();
assertThat(it.next().asArray()).hasSize(3);
assertThat(it.hasNext()).isTrue();
assertThrows(UnsupportedOperationException.class, it::remove);
assertThat(it.next().asObject()).isNotNull();
assertThat(it.hasNext()).isFalse();
assertThrows(NoSuchElementException.class, it::next);
}
/**
* Test the array stream.
*/
@Test
public void testArrayStream() {
var json = TestUtils.getJSON("datatypes");
var array = json.get("array").asArray();
var streamValues = array.stream().collect(Collectors.toList());
var iteratorValues = new ArrayList();
for (var value : array) {
iteratorValues.add(value);
}
assertThat(streamValues).containsAll(iteratorValues);
}
/**
* Test all getters on existing values.
*/
@Test
public void testGetter() {
var date = LocalDate.of(2016, 1, 8).atStartOfDay(ZoneId.of("UTC")).toInstant();
var json = TestUtils.getJSON("datatypes");
assertThat(json.get("text").asString()).isEqualTo("lorem ipsum");
assertThat(json.get("number").asInt()).isEqualTo(123);
assertThat(json.get("boolean").asBoolean()).isTrue();
assertThat(json.get("uri").asURI()).isEqualTo(URI.create("mailto:foo@example.com"));
assertThat(json.get("url").asURL()).isEqualTo(url("http://example.com"));
assertThat(json.get("date").asInstant()).isEqualTo(date);
assertThat(json.get("status").asStatus()).isEqualTo(Status.VALID);
assertThat(json.get("binary").asBinary()).isEqualTo("Chainsaw".getBytes());
assertThat(json.get("duration").asDuration()).hasSeconds(86400L);
assertThat(json.get("text").isPresent()).isTrue();
assertThat(json.get("text").optional().isPresent()).isTrue();
assertThat(json.get("text").map(Value::asString).isPresent()).isTrue();
var array = json.get("array").asArray();
assertThat(array.get(0).asString()).isEqualTo("foo");
assertThat(array.get(1).asInt()).isEqualTo(987);
var array2 = array.get(2).asArray();
assertThat(array2.get(0).asInt()).isEqualTo(1);
assertThat(array2.get(1).asInt()).isEqualTo(2);
assertThat(array2.get(2).asInt()).isEqualTo(3);
var sub = array.get(3).asObject();
assertThat(sub.get("test").asString()).isEqualTo("ok");
var encodedSub = json.get("encoded").asEncodedObject();
assertThatJson(encodedSub.toString()).isEqualTo("{\"key\":\"value\"}");
var problem = json.get("problem").asProblem(BASE_URL);
assertThat(problem).isNotNull();
assertThat(problem.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:rateLimited"));
assertThat(problem.getDetail().orElseThrow()).isEqualTo("too many requests");
assertThat(problem.getInstance().orElseThrow())
.isEqualTo(URI.create("https://example.com/documents/errors.html"));
}
/**
* Test that getters are null safe.
*/
@Test
public void testNullGetter() {
var json = TestUtils.getJSON("datatypes");
assertThat(json.get("none")).isNotNull();
assertThat(json.get("none").isPresent()).isFalse();
assertThat(json.get("none").optional().isPresent()).isFalse();
assertThat(json.get("none").map(Value::asString).isPresent()).isFalse();
assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(() -> json.getFeature("none"))
.withMessage("Server does not support none");
assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(() -> json.get("none").onFeature("my-feature"))
.withMessage("Server does not support my-feature");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asString(),
"asString");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asURI(),
"asURI");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asURL(),
"asURL");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asInstant(),
"asInstant");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asDuration(),
"asDuration");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asObject(),
"asObject");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asEncodedObject(),
"asEncodedObject");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asStatus(),
"asStatus");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asBinary(),
"asBinary");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asProblem(BASE_URL),
"asProblem");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asInt(),
"asInt");
assertThrows(AcmeProtocolException.class,
() -> json.get("none").asBoolean(),
"asBoolean");
}
/**
* Test that wrong getters return an exception.
*/
@Test
public void testWrongGetter() {
var json = TestUtils.getJSON("datatypes");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asObject(),
"asObject");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asEncodedObject(),
"asEncodedObject");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asArray(),
"asArray");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asInt(),
"asInt");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asURI(),
"asURI");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asURL(),
"asURL");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asInstant(),
"asInstant");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asDuration(),
"asDuration");
assertThrows(AcmeProtocolException.class,
() -> json.get("text").asProblem(BASE_URL),
"asProblem");
}
/**
* Test that serialization works correctly.
*/
@Test
public void testSerialization() throws IOException, ClassNotFoundException {
var originalJson = TestUtils.getJSON("newAuthorizationResponse");
// Serialize
byte[] data;
try (var out = new ByteArrayOutputStream()) {
try (var oos = new ObjectOutputStream(out)) {
oos.writeObject(originalJson);
}
data = out.toByteArray();
}
// Deserialize
JSON testJson;
try (var in = new ByteArrayInputStream(data)) {
try (var ois = new ObjectInputStream(in)) {
testJson = (JSON) ois.readObject();
}
}
assertThat(testJson).isNotSameAs(originalJson);
assertThat(testJson.toString()).isNotEmpty();
assertThatJson(testJson.toString()).isEqualTo(originalJson.toString());
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/JoseUtilsTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2019 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.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URL;
import java.util.Base64;
import java.util.HashMap;
import javax.crypto.SecretKey;
import org.jose4j.jwk.PublicJsonWebKey;
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}.
*/
public class JoseUtilsTest {
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
/**
* Test if a JOSE ACME POST request is correctly created.
*/
@Test
public void testCreateJosePostRequest() throws Exception {
var resourceUrl = url("http://example.com/acme/resource");
var accountKey = TestUtils.createKeyPair();
var nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var payload = new JSONBuilder();
payload.put("foo", 123);
payload.put("bar", "a-string");
var jose = JoseUtils
.createJoseRequest(resourceUrl, accountKey, payload, nonce, TestUtils.ACCOUNT_URL)
.toMap();
var encodedHeader = jose.get("protected").toString();
var encodedSignature = jose.get("signature").toString();
var encodedPayload = jose.get("payload").toString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"nonce\":\"").append(nonce).append("\",");
expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"');
expectedHeader.append('}');
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
.isEqualTo(expectedHeader.toString());
assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8))
.isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(accountKey.getPublic());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test if a JOSE ACME POST-as-GET request is correctly created.
*/
@Test
public void testCreateJosePostAsGetRequest() throws Exception {
var resourceUrl = url("http://example.com/acme/resource");
var accountKey = TestUtils.createKeyPair();
var nonce = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes());
var jose = JoseUtils
.createJoseRequest(resourceUrl, accountKey, null, nonce, TestUtils.ACCOUNT_URL)
.toMap();
var encodedHeader = jose.get("protected").toString();
var encodedSignature = jose.get("signature").toString();
var encodedPayload = jose.get("payload").toString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"nonce\":\"").append(nonce).append("\",");
expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"kid\":\"").append(TestUtils.ACCOUNT_URL).append('"');
expectedHeader.append('}');
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
.isEqualTo(expectedHeader.toString());
assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEmpty();
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(accountKey.getPublic());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test if a JOSE ACME Key-Change request is correctly created.
*/
@Test
public void testCreateJoseKeyChangeRequest() throws Exception {
var resourceUrl = url("http://example.com/acme/resource");
var accountKey = TestUtils.createKeyPair();
var payload = new JSONBuilder();
payload.put("foo", 123);
payload.put("bar", "a-string");
var jose = JoseUtils
.createJoseRequest(resourceUrl, accountKey, payload, null, null)
.toMap();
var encodedHeader = jose.get("protected").toString();
var encodedSignature = jose.get("signature").toString();
var encodedPayload = jose.get("payload").toString();
var expectedHeader = new StringBuilder();
expectedHeader.append('{');
expectedHeader.append("\"url\":\"").append(resourceUrl).append("\",");
expectedHeader.append("\"alg\":\"RS256\",");
expectedHeader.append("\"jwk\": {");
expectedHeader.append("\"kty\": \"").append(TestUtils.KTY).append("\",");
expectedHeader.append("\"e\": \"").append(TestUtils.E).append("\",");
expectedHeader.append("\"n\": \"").append(TestUtils.N).append("\"}");
expectedHeader.append("}");
assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8))
.isEqualTo(expectedHeader.toString());
assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8))
.isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}");
assertThat(encodedSignature).isNotEmpty();
var jws = new JsonWebSignature();
jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature));
jws.setKey(accountKey.getPublic());
assertThat(jws.verifySignature()).isTrue();
}
/**
* Test if an external account binding is correctly created.
*/
@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(keyAlg);
var resourceUrl = url("http://example.com/acme/resource");
var binding = JoseUtils.createExternalAccountBinding(
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, macAlg);
}
/**
* Test if public key is correctly converted to JWK structure.
*/
@Test
public void testPublicKeyToJWK() throws Exception {
var json = JoseUtils.publicKeyToJWK(TestUtils.createKeyPair().getPublic());
assertThat(json).hasSize(3);
assertThat(json.get("kty")).isEqualTo(TestUtils.KTY);
assertThat(json.get("n")).isEqualTo(TestUtils.N);
assertThat(json.get("e")).isEqualTo(TestUtils.E);
}
/**
* Test if JWK structure is correctly converted to public key.
*/
@Test
public void testJWKToPublicKey() throws Exception {
var json = new HashMap();
json.put("kty", TestUtils.KTY);
json.put("n", TestUtils.N);
json.put("e", TestUtils.E);
var key = JoseUtils.jwkToPublicKey(json);
assertThat(key.getEncoded()).isEqualTo(TestUtils.createKeyPair().getPublic().getEncoded());
}
/**
* Test if thumbprint is correctly computed.
*/
@Test
public void testThumbprint() throws Exception {
var thumb = JoseUtils.thumbprint(TestUtils.createKeyPair().getPublic());
var encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(thumb);
assertThat(encoded).isEqualTo(TestUtils.THUMBPRINT);
}
/**
* Test if RSA using SHA-256 keys are properly detected.
*/
@Test
public void testRsaKey() throws Exception {
var rsaKeyPair = TestUtils.createKeyPair();
var jwk = PublicJsonWebKey.Factory.newPublicJwk(rsaKeyPair.getPublic());
var type = JoseUtils.keyAlgorithm(jwk);
assertThat(type).isEqualTo("RS256");
}
/**
* Test if ECDSA using NIST P-256 curve and SHA-256 keys are properly detected.
*/
@Test
public void testP256ECKey() throws Exception {
var ecKeyPair = TestUtils.createECKeyPair("secp256r1");
var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
var type = JoseUtils.keyAlgorithm(jwk);
assertThat(type).isEqualTo("ES256");
}
/**
* Test if ECDSA using NIST P-384 curve and SHA-384 keys are properly detected.
*/
@Test
public void testP384ECKey() throws Exception {
var ecKeyPair = TestUtils.createECKeyPair("secp384r1");
var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
var type = JoseUtils.keyAlgorithm(jwk);
assertThat(type).isEqualTo("ES384");
}
/**
* Test if ECDSA using NIST P-521 curve and SHA-512 keys are properly detected.
*/
@Test
public void testP521ECKey() throws Exception {
var ecKeyPair = TestUtils.createECKeyPair("secp521r1");
var jwk = PublicJsonWebKey.Factory.newPublicJwk(ecKeyPair.getPublic());
var type = JoseUtils.keyAlgorithm(jwk);
assertThat(type).isEqualTo("ES512");
}
/**
* Test if MAC key algorithms are properly detected.
*/
@Test
public void testMacKey() throws Exception {
assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-256"))).isEqualTo("HS256");
assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-384"))).isEqualTo("HS384");
assertThat(JoseUtils.macKeyAlgorithm(TestUtils.createSecretKey("SHA-512"))).isEqualTo("HS512");
}
/**
* Asserts that the serialized external account binding is valid. Unit test fails if
* the account binding is invalid.
*
* @param serialized
* Serialized external account binding JOSE structure
* @param resourceUrl
* Expected resource {@link URL}
* @param keyIdentifier
* 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 macAlg) {
try {
var jws = new JsonWebSignature();
jws.setCompactSerialization(serialized);
jws.setKey(macKey);
assertThat(jws.verifySignature()).isTrue();
assertThat(jws.getHeader("url")).isEqualTo(resourceUrl.toString());
assertThat(jws.getHeader("kid")).isEqualTo(keyIdentifier);
assertThat(jws.getHeader("alg")).isEqualTo(macAlg);
var decodedPayload = jws.getPayload();
var expectedPayload = new StringBuilder();
expectedPayload.append('{');
expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\",");
expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\",");
expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\"");
expectedPayload.append("}");
assertThatJson(decodedPayload).isEqualTo(expectedPayload.toString());
} catch (JoseException ex) {
fail(ex);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/toolbox/TestUtils.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.List;
import java.util.TreeMap;
import javax.crypto.SecretKey;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.json.JsonUtil;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.JsonWebKey.OutputControlLevel;
import org.jose4j.keys.HmacKey;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.provider.AcmeProvider;
/**
* Some utility methods for unit tests.
*/
public final class TestUtils {
public static final String N = "pZsTKY41y_CwgJ0VX7BmmGs_7UprmXQMGPcnSbBeJAjZHA9SyyJKaWv4fNUdBIAX3Y2QoZixj50nQLyLv2ng3pvEoRL0sx9ZHgp5ndAjpIiVQ_8V01TTYCEDUc9ii7bjVkgFAb4ValZGFJZ54PcCnAHvXi5g0ELORzGcTuRqHVAUckMV2otr0g0u_5bWMm6EMAbBrGQCgUGjbZQHjava1Y-5tHXZkPBahJ2LvKRqMmJUlr0anKuJJtJUG03DJYAxABv8YAaXFBnGw6kKJRpUFAC55ry4sp4kGy0NrK2TVWmZW9kStniRv4RaJGI9aZGYwQy2kUykibBNmWEQUlIwIw";
public static final String E = "AQAB";
public static final String KTY = "RSA";
public static final String THUMBPRINT = "HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0";
public static final String D_N = "tP7p9wOe0NWocwLu7h233i1JqUPW1MeLeilyHY7oMKnXZFyf1l0saqLcrBtOj3EyaG6qVfpiLEWEIiuWclPYSR_QSt9lCi9xAoWbYq9-mqseehXPaejynlIMsP2UiCAenSHjJEer6Ug6nFelGVgav3mypwYFUdvc18wI00clKYhRAc4dZodilRzDTLy95V1S3RCxGf-lE0XYg7ieO_ovSMERtH_7NsjZnBiaE7mwm0YZzreCr8oSuHwhC63kgY27FnCgH0h63LICSPVVDJZPLcWAmSXv1k0qoVTsRzFutRN6RB_96wqTTBi8Qm98lyCpXcsxa3BH-4TCvLEaa2KkeQ";
public static final String D_E = "AQAB";
public static final String D_KTY = "RSA";
public static final String D_THUMBPRINT = "0VPbh7-I6swlkBu0TrNKSQp6d69bukzeQA0ksuX3FFs";
public static final String ACME_SERVER_URI = "https://example.com/acme";
public static final String ACCOUNT_URL = "https://example.com/acme/account/1";
public static final String DUMMY_NONCE = Base64.getUrlEncoder().withoutPadding().encodeToString("foo-nonce-foo".getBytes());
public static final String CERT_ISSUER = "Pebble Intermediate CA 645fc5";
public static final NetworkSettings DEFAULT_NETWORK_SETTINGS = new NetworkSettings();
private TestUtils() {
// utility class without constructor
}
/**
* Reads a resource as byte array.
*
* @param name
* Resource name
* @return Resource content as byte array.
*/
public static byte[] getResourceAsByteArray(String name) throws IOException {
var buffer = new byte[2048];
try (var in = TestUtils.class.getResourceAsStream(name);
var out = new ByteArrayOutputStream()) {
int len;
while ((len = in.read(buffer)) >= 0) {
out.write(buffer, 0, len);
}
return out.toByteArray();
}
}
/**
* Reads a JSON string from json test files and parses it.
*
* @param key
* JSON resource
* @return Parsed JSON resource
*/
public static JSON getJSON(String key) {
try {
return JSON.parse(TestUtils.class.getResourceAsStream("/json/" + key + ".json"));
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
/**
* Creates a {@link Session} instance. It uses {@link #ACME_SERVER_URI} as server URI.
*/
public static Session session() {
return new Session(URI.create(ACME_SERVER_URI));
}
/**
* Creates a {@link Login} instance. It uses {@link #ACME_SERVER_URI} as server URI,
* {@link #ACCOUNT_URL} as account URL, and a random key pair.
*/
public static Login login() {
try {
return session().login(URI.create(ACCOUNT_URL).toURL(), createKeyPair());
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
/**
* Creates an {@link URL} from a String. Only throws a runtime exception if the URL is
* malformed.
*
* @param url
* URL to use
* @return {@link URL} object
*/
public static URL url(String url) {
try {
return URI.create(url).toURL();
} catch (MalformedURLException ex) {
throw new IllegalArgumentException(url, ex);
}
}
/**
* Creates a {@link Session} instance. It uses {@link #ACME_SERVER_URI} as server URI.
*
* @param provider
* {@link AcmeProvider} to be used in this session
*/
public static Session session(final AcmeProvider provider) {
return new Session(URI.create(ACME_SERVER_URI)) {
@Override
public AcmeProvider provider() {
return provider;
}
@Override
public Connection connect() {
return provider.connect(getServerUri(), DEFAULT_NETWORK_SETTINGS, getHttpClient());
}
};
}
/**
* Creates a standard account {@link KeyPair} for testing. The key pair is read from a
* test resource and is guaranteed not to change between test runs.
*
* The constants {@link #N}, {@link #E}, {@link #KTY} and {@link #THUMBPRINT} are
* related to the returned key pair and can be used for asserting results.
*
* @return {@link KeyPair} for testing
*/
public static KeyPair createKeyPair() {
try {
var keyFactory = KeyFactory.getInstance(KTY);
var publicKeySpec = new X509EncodedKeySpec(getResourceAsByteArray("/public.key"));
var publicKey = keyFactory.generatePublic(publicKeySpec);
var privateKeySpec = new PKCS8EncodedKeySpec(getResourceAsByteArray("/private.key"));
var privateKey = keyFactory.generatePrivate(privateKeySpec);
return new KeyPair(publicKey, privateKey);
} catch (NoSuchAlgorithmException | InvalidKeySpecException | IOException ex) {
throw new IllegalStateException(ex);
}
}
/**
* Creates a standard domain key pair for testing. This keypair is read from a test
* resource and is guaranteed not to change between test runs.
*
* The constants {@link #D_N}, {@link #D_E}, {@link #D_KTY} and {@link #D_THUMBPRINT}
* are related to the returned key pair and can be used for asserting results.
*
* @return {@link KeyPair} for testing
*/
public static KeyPair createDomainKeyPair() throws IOException {
try {
var keyFactory = KeyFactory.getInstance(KTY);
var publicKeySpec = new X509EncodedKeySpec(getResourceAsByteArray("/domain-public.key"));
var publicKey = keyFactory.generatePublic(publicKeySpec);
var privateKeySpec = new PKCS8EncodedKeySpec(getResourceAsByteArray("/domain-private.key"));
var privateKey = keyFactory.generatePrivate(privateKeySpec);
return new KeyPair(publicKey, privateKey);
} catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
throw new IOException(ex);
}
}
/**
* Creates a random ECC key pair with the given curve name.
*
* @param name
* Curve name
* @return {@link KeyPair} for testing
*/
public static KeyPair createECKeyPair(String name) throws IOException {
try {
var ecSpec = new ECGenParameterSpec(name);
var keyGen = KeyPairGenerator.getInstance("EC");
keyGen.initialize(ecSpec, new SecureRandom());
return keyGen.generateKeyPair();
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException ex) {
throw new IOException(ex);
}
}
/**
* Creates a HMAC key using the given hash algorithm.
*
* @param algorithm
* Name of the hash algorithm to be used
* @return {@link SecretKey} for testing
*/
public static SecretKey createSecretKey(String algorithm) throws IOException {
try {
var md = MessageDigest.getInstance(algorithm);
md.update("Turpentine".getBytes()); // A random password
var macKey = md.digest();
return new HmacKey(macKey);
} catch (NoSuchAlgorithmException ex) {
throw new IOException(ex);
}
}
/**
* Creates a standard certificate chain for testing. This certificate is read from a
* test resource and is guaranteed not to change between test runs.
*
* @param resource
* Name of the resource
* @return List of {@link X509Certificate} for testing
*/
public static List createCertificate(String resource) throws IOException {
try (var in = TestUtils.class.getResourceAsStream(resource)) {
var cf = CertificateFactory.getInstance("X.509");
return cf.generateCertificates(in).stream()
.map(c -> (X509Certificate) c)
.collect(toUnmodifiableList());
} catch (CertificateException ex) {
throw new IOException(ex);
}
}
/**
* Creates a {@link Problem} with the given type and details.
*
* @param type
* Problem type
* @param detail
* Problem details
* @param instance
* Instance, or {@code null}
* @return Created {@link Problem} object
*/
public static Problem createProblem(URI type, String detail, @Nullable URL instance) {
var jb = new JSONBuilder();
jb.put("type", type);
jb.put("detail", detail);
if (instance != null) {
jb.put("instance", instance);
}
return new Problem(jb.toJSON(), url("https://example.com/acme/1"));
}
/**
* Generates a new keypair for unit tests, and return its N, E, KTY and THUMBPRINT
* parameters to be set in the {@link TestUtils} class.
*/
public static void main(String... args) throws Exception {
var keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
var keyPair = keyGen.generateKeyPair();
try (var out = new FileOutputStream("public.key")) {
out.write(keyPair.getPublic().getEncoded());
}
try (var out = new FileOutputStream("private.key")) {
out.write(keyPair.getPrivate().getEncoded());
}
var jwk = JsonWebKey.Factory.newJwk(keyPair.getPublic());
var params = new TreeMap<>(jwk.toParams(OutputControlLevel.PUBLIC_ONLY));
var md = MessageDigest.getInstance("SHA-256");
md.update(JsonUtil.toJson(params).getBytes(UTF_8));
var thumbprint = md.digest();
System.out.println("N = " + params.get("n"));
System.out.println("E = " + params.get("e"));
System.out.println("KTY = " + params.get("kty"));
System.out.println("THUMBPRINT = " + Base64.getUrlEncoder().withoutPadding().encodeToString(thumbprint));
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/util/CSRBuilderTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.Security;
import java.util.Arrays;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1IA5String;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Identifier;
/**
* Unit tests for {@link CSRBuilder}.
*/
public class CSRBuilderTest {
private static KeyPair testKey;
private static KeyPair testEcKey;
/**
* Add provider, create some key pairs
*/
@BeforeAll
public static void setup() {
Security.addProvider(new BouncyCastleProvider());
testKey = KeyPairUtils.createKeyPair(512);
testEcKey = KeyPairUtils.createECKeyPair("secp256r1");
}
/**
* Test if the generated CSR is plausible.
*/
@Test
public void testGenerate() throws IOException {
var builder = createBuilderWithValues();
builder.sign(testKey);
var csr = builder.getCSR();
assertThat(csr).isNotNull();
assertThat(csr.getEncoded()).isEqualTo(builder.getEncoded());
csrTest(csr);
writerTest(builder);
}
/**
* Test if the generated CSR is plausible using a ECDSA key.
*/
@Test
public void testECCGenerate() throws IOException {
var builder = createBuilderWithValues();
builder.sign(testEcKey);
var csr = builder.getCSR();
assertThat(csr).isNotNull();
assertThat(csr.getEncoded()).isEqualTo(builder.getEncoded());
csrTest(csr);
writerTest(builder);
}
/**
* Make sure an exception is thrown when no domain is set.
*/
@Test
public void testNoDomain() {
var ise = assertThrows(IllegalStateException.class, () -> {
var builder = new CSRBuilder();
builder.sign(testKey);
});
assertThat(ise.getMessage())
.isEqualTo("No domain or IP address was set");
}
/**
* Make sure an exception is thrown when an unknown identifier type is used.
*/
@Test
public void testUnknownType() {
var iae = assertThrows(IllegalArgumentException.class, () -> {
var builder = new CSRBuilder();
builder.addIdentifier(new Identifier("UnKnOwN", "123"));
});
assertThat(iae.getMessage())
.isEqualTo("Unknown identifier type: UnKnOwN");
}
/**
* Make sure all getters will fail if the CSR is not signed.
*/
@Test
public void testNoSign() {
var builder = new CSRBuilder();
assertThatIllegalStateException()
.isThrownBy(builder::getCSR)
.as("getCSR()")
.withMessage("sign CSR first");
assertThatIllegalStateException()
.isThrownBy(builder::getEncoded)
.as("getCSR()")
.withMessage("sign CSR first");
assertThatIllegalStateException()
.isThrownBy(() -> {
try (StringWriter w = new StringWriter()) {
builder.write(w);
}
})
.as("builder.write()")
.withMessage("sign CSR first");
}
/**
* Checks that addValue behaves correctly in dependence of the
* attributes being added. If a common name is set, it should
* be handled in the same way when it's added by using
* addDomain
*/
@Test
public void testAddAttrValues() {
var builder = new CSRBuilder();
String invAttNameExMessage = assertThrows(IllegalArgumentException.class,
() -> X500Name.getDefaultStyle().attrNameToOID("UNKNOWNATT")).getMessage();
assertThat(builder.toString()).isEqualTo("");
assertThatNullPointerException()
.isThrownBy(() -> new CSRBuilder().addValue((String) null, "value"))
.as("addValue(String, String)");
assertThatNullPointerException()
.isThrownBy(() -> new CSRBuilder().addValue((ASN1ObjectIdentifier) null, "value"))
.as("addValue(ASN1ObjectIdentifier, String)");
assertThatNullPointerException()
.isThrownBy(() -> new CSRBuilder().addValue("C", null))
.as("addValue(String, null)");
assertThatIllegalArgumentException()
.isThrownBy(() -> new CSRBuilder().addValue("UNKNOWNATT", "val"))
.as("addValue(String, null)")
.withMessage(invAttNameExMessage);
assertThat(builder.toString()).isEqualTo("");
builder.addValue("C", "DE");
assertThat(builder.toString()).isEqualTo("C=DE");
builder.addValue("E", "contact@example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com");
builder.addValue("CN", "firstcn.example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com");
builder.addValue("CN", "scnd.example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com,DNS=scnd.example.com");
builder = new CSRBuilder();
builder.addValue(BCStyle.C, "DE");
assertThat(builder.toString()).isEqualTo("C=DE");
builder.addValue(BCStyle.EmailAddress, "contact@example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com");
builder.addValue(BCStyle.CN, "firstcn.example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com");
builder.addValue(BCStyle.CN, "scnd.example.com");
assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com,DNS=scnd.example.com");
}
private CSRBuilder createBuilderWithValues() throws UnknownHostException {
var builder = new CSRBuilder();
builder.addDomain("abc.de");
builder.addDomain("fg.hi");
builder.addDomains("jklm.no", "pqr.st");
builder.addDomains(Arrays.asList("uv.wx", "y.z"));
builder.addDomain("*.wild.card");
builder.addIP(InetAddress.getByName("192.0.2.1"));
builder.addIP(InetAddress.getByName("192.0.2.2"));
builder.addIPs(InetAddress.getByName("198.51.100.1"), InetAddress.getByName("198.51.100.2"));
builder.addIPs(Arrays.asList(InetAddress.getByName("2001:db8::1"), InetAddress.getByName("2001:db8::2")));
builder.addIdentifier(Identifier.dns("ide1.nt"));
builder.addIdentifier(Identifier.ip("203.0.113.5"));
builder.addIdentifiers(Identifier.dns("ide2.nt"), Identifier.ip("203.0.113.6"));
builder.addIdentifiers(Arrays.asList(Identifier.dns("ide3.nt"), Identifier.ip("203.0.113.7")));
builder.setCommonName("abc.de");
builder.setCountry("XX");
builder.setLocality("Testville");
builder.setOrganization("Testing Co");
builder.setOrganizationalUnit("Testunit");
builder.setState("ABC");
assertThat(builder.toString()).isEqualTo("CN=abc.de,C=XX,L=Testville,O=Testing Co,"
+ "OU=Testunit,ST=ABC,"
+ "DNS=abc.de,DNS=fg.hi,DNS=jklm.no,DNS=pqr.st,DNS=uv.wx,DNS=y.z,DNS=*.wild.card,"
+ "DNS=ide1.nt,DNS=ide2.nt,DNS=ide3.nt,"
+ "IP=192.0.2.1,IP=192.0.2.2,IP=198.51.100.1,IP=198.51.100.2,"
+ "IP=2001:db8:0:0:0:0:0:1,IP=2001:db8:0:0:0:0:0:2,"
+ "IP=203.0.113.5,IP=203.0.113.6,IP=203.0.113.7");
return builder;
}
/**
* Checks if the CSR contains the right parameters.
*
* This is not supposed to be a Bouncy Castle test. If the
* {@link PKCS10CertificationRequest} contains the right parameters, we assume that
* Bouncy Castle encodes it properly.
*/
private void csrTest(PKCS10CertificationRequest csr) {
var name = csr.getSubject();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(name.getRDNs(BCStyle.CN)).as("CN")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("abc.de");
softly.assertThat(name.getRDNs(BCStyle.C)).as("C")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("XX");
softly.assertThat(name.getRDNs(BCStyle.L)).as("L")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("Testville");
softly.assertThat(name.getRDNs(BCStyle.O)).as("O")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("Testing Co");
softly.assertThat(name.getRDNs(BCStyle.OU)).as("OU")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("Testunit");
softly.assertThat(name.getRDNs(BCStyle.ST)).as("ST")
.extracting(rdn -> rdn.getFirst().getValue().toString())
.contains("ABC");
}
var attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest);
assertThat(attr).hasSize(1);
var extensions = attr[0].getAttrValues().toArray();
assertThat(extensions).hasSize(1);
var names = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName);
assertThat(names.getNames())
.filteredOn(gn -> gn.getTagNo() == GeneralName.dNSName)
.extracting(gn -> ASN1IA5String.getInstance(gn.getName()).getString())
.containsExactlyInAnyOrder("abc.de", "fg.hi", "jklm.no", "pqr.st",
"uv.wx", "y.z", "*.wild.card", "ide1.nt", "ide2.nt", "ide3.nt");
assertThat(names.getNames())
.filteredOn(gn -> gn.getTagNo() == GeneralName.iPAddress)
.extracting(gn -> getIP(gn.getName()).getHostAddress())
.containsExactlyInAnyOrder("192.0.2.1", "192.0.2.2", "198.51.100.1",
"198.51.100.2", "2001:db8:0:0:0:0:0:1", "2001:db8:0:0:0:0:0:2",
"203.0.113.5", "203.0.113.6", "203.0.113.7");
}
/**
* Checks if the {@link CSRBuilder#write(java.io.Writer)} method generates a correct
* CSR PEM file.
*/
private void writerTest(CSRBuilder builder) throws IOException {
// Write CSR to PEM
String pem;
try (var out = new StringWriter()) {
builder.write(out);
pem = out.toString();
}
// Make sure PEM file is properly formatted
assertThat(pem).matches(
"-----BEGIN CERTIFICATE REQUEST-----[\\r\\n]+"
+ "([a-zA-Z0-9/+=]+[\\r\\n]+)+"
+ "-----END CERTIFICATE REQUEST-----[\\r\\n]*");
// Read CSR from PEM
PKCS10CertificationRequest readCsr;
try (var parser = new PEMParser(new StringReader(pem))) {
readCsr = (PKCS10CertificationRequest) parser.readObject();
}
// Verify that both keypairs are the same
assertThat(builder.getCSR()).isNotSameAs(readCsr);
assertThat(builder.getEncoded()).isEqualTo(readCsr.getEncoded());
// OutputStream is identical?
byte[] pemBytes;
try (var baos = new ByteArrayOutputStream()) {
builder.write(baos);
pemBytes = baos.toByteArray();
}
assertThat(new String(pemBytes, StandardCharsets.UTF_8)).isEqualTo(pem);
}
/**
* Fetches the {@link InetAddress} from the given iPAddress record.
*
* @param name
* Name to convert
* @return {@link InetAddress}
* @throws IllegalArgumentException
* if the IP address could not be read
*/
private static InetAddress getIP(ASN1Encodable name) {
try {
return InetAddress.getByAddress(DEROctetString.getInstance(name).getOctets());
} catch (UnknownHostException ex) {
throw new IllegalArgumentException(ex);
}
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/util/CertificateUtilsTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import static java.time.temporal.ChronoUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Modifier;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.BERTags;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.toolbox.AcmeUtils;
/**
* Unit tests for {@link CertificateUtils}.
*/
public class CertificateUtilsTest {
/**
* Test if {@link CertificateUtils#readCSR(InputStream)} reads an identical CSR.
*/
@Test
public void testReadCSR() throws IOException {
var keypair = KeyPairUtils.createKeyPair(2048);
var builder = new CSRBuilder();
builder.addDomains("example.com", "example.org");
builder.sign(keypair);
var original = builder.getCSR();
byte[] pemFile;
try (var baos = new ByteArrayOutputStream()) {
builder.write(baos);
pemFile = baos.toByteArray();
}
try (var bais = new ByteArrayInputStream(pemFile)) {
var read = CertificateUtils.readCSR(bais);
assertThat(original.getEncoded()).isEqualTo(read.getEncoded());
}
}
/**
* Test that constructor is private.
*/
@Test
public void testPrivateConstructor() throws Exception {
var constructor = CertificateUtils.class.getDeclaredConstructor();
assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue();
constructor.setAccessible(true);
constructor.newInstance();
}
/**
* Test if
* {@link CertificateUtils#createTlsAlpn01Certificate(KeyPair, Identifier, byte[])}
* with domain name creates a good certificate.
*/
@Test
public void testCreateTlsAlpn01Certificate() throws Exception {
var keypair = KeyPairUtils.createKeyPair(2048);
var subject = "example.com";
var acmeValidationV1 = AcmeUtils.sha256hash("rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ");
var cert = CertificateUtils.createTlsAlpn01Certificate(keypair, Identifier.dns(subject), acmeValidationV1);
var now = Instant.now();
var end = now.plus(Duration.ofDays(8));
assertThat(cert).isNotNull();
assertThat(cert.getNotAfter()).isAfter(Date.from(now));
assertThat(cert.getNotAfter()).isBefore(Date.from(end));
assertThat(cert.getNotBefore()).isBeforeOrEqualTo(Date.from(now));
assertThat(cert.getSubjectX500Principal().getName()).isEqualTo("CN=acme.invalid");
assertThat(getSANs(cert)).contains(subject);
assertThat(cert.getCriticalExtensionOIDs()).contains(TlsAlpn01Challenge.ACME_VALIDATION_OID);
var encodedExtensionValue = cert.getExtensionValue(TlsAlpn01Challenge.ACME_VALIDATION_OID);
assertThat(encodedExtensionValue).isNotNull();
try (var asn = new ASN1InputStream(new ByteArrayInputStream(encodedExtensionValue))) {
var derOctetString = (DEROctetString) asn.readObject();
var test = new byte[acmeValidationV1.length + 2];
test[0] = BERTags.OCTET_STRING;
test[1] = (byte) acmeValidationV1.length;
System.arraycopy(acmeValidationV1, 0, test, 2, acmeValidationV1.length);
assertThat(derOctetString.getOctets()).isEqualTo(test);
}
cert.verify(keypair.getPublic());
}
/**
* Test if
* {@link CertificateUtils#createTlsAlpn01Certificate(KeyPair, Identifier, byte[])}
* with IP creates a good certificate.
*/
@Test
public void testCreateTlsAlpn01CertificateWithIp() throws IOException, CertificateParsingException {
var keypair = KeyPairUtils.createKeyPair(2048);
var subject = InetAddress.getLocalHost();
var acmeValidationV1 = AcmeUtils.sha256hash("rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ");
var cert = CertificateUtils.createTlsAlpn01Certificate(keypair, Identifier.ip(subject), acmeValidationV1);
assertThat(cert.getSubjectX500Principal().getName()).isEqualTo("CN=acme.invalid");
assertThat(getIpSANs(cert)).contains(subject);
}
/**
* Test if {@link CertificateUtils#createTestRootCertificate(String, Instant, Instant,
* KeyPair)} generates a valid root certificate.
*/
@Test
public void testCreateTestRootCertificate() throws Exception {
var keypair = KeyPairUtils.createKeyPair(2048);
var subject = "CN=Test Root Certificate";
var notBefore = Instant.now().truncatedTo(SECONDS);
var notAfter = notBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS);
var cert = CertificateUtils.createTestRootCertificate(subject,
notBefore, notAfter, keypair);
assertThat(cert.getIssuerX500Principal().getName()).isEqualTo(subject);
assertThat(cert.getSubjectX500Principal().getName()).isEqualTo(subject);
assertThat(cert.getNotBefore().toInstant()).isEqualTo(notBefore);
assertThat(cert.getNotAfter().toInstant()).isEqualTo(notAfter);
assertThat(cert.getSerialNumber()).isNotNull();
assertThat(cert.getPublicKey()).isEqualTo(keypair.getPublic());
cert.verify(cert.getPublicKey()); // self-signed
}
/**
* Test if {@link CertificateUtils#createTestIntermediateCertificate(String, Instant,
* Instant, PublicKey, X509Certificate, PrivateKey)} generates a valid intermediate
* certificate.
*/
@Test
public void testCreateTestIntermediateCertificate() throws Exception {
var rootKeypair = KeyPairUtils.createKeyPair(2048);
var rootSubject = "CN=Test Root Certificate";
var rootNotBefore = Instant.now().minus(Duration.ofDays(1)).truncatedTo(SECONDS);
var rootNotAfter = rootNotBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS);
var rootCert = CertificateUtils.createTestRootCertificate(rootSubject,
rootNotBefore, rootNotAfter, rootKeypair);
var keypair = KeyPairUtils.createKeyPair(2048);
var subject = "CN=Test Intermediate Certificate";
var notBefore = Instant.now().truncatedTo(SECONDS);
var notAfter = notBefore.plus(Duration.ofDays(7)).truncatedTo(SECONDS);
var cert = CertificateUtils.createTestIntermediateCertificate(subject,
notBefore, notAfter, keypair.getPublic(), rootCert, rootKeypair.getPrivate());
assertThat(cert.getIssuerX500Principal().getName()).isEqualTo(rootSubject);
assertThat(cert.getSubjectX500Principal().getName()).isEqualTo(subject);
assertThat(cert.getNotBefore().toInstant()).isEqualTo(notBefore);
assertThat(cert.getNotAfter().toInstant()).isEqualTo(notAfter);
assertThat(cert.getSerialNumber()).isNotNull();
assertThat(cert.getSerialNumber()).isNotEqualTo(rootCert.getSerialNumber());
assertThat(cert.getPublicKey()).isEqualTo(keypair.getPublic());
cert.verify(rootKeypair.getPublic()); // signed by root
}
/**
* Test if {@link CertificateUtils#createTestCertificate(PKCS10CertificationRequest,
* Instant, Instant, X509Certificate, PrivateKey)} generates a valid certificate.
*/
@Test
public void testCreateTestCertificate() throws Exception {
var rootKeypair = KeyPairUtils.createKeyPair(2048);
var rootSubject = "CN=Test Root Certificate";
var rootNotBefore = Instant.now().minus(Duration.ofDays(1)).truncatedTo(SECONDS);
var rootNotAfter = rootNotBefore.plus(Duration.ofDays(14)).truncatedTo(SECONDS);
var rootCert = CertificateUtils.createTestRootCertificate(rootSubject,
rootNotBefore, rootNotAfter, rootKeypair);
var keypair = KeyPairUtils.createKeyPair(2048);
var notBefore = Instant.now().truncatedTo(SECONDS);
var notAfter = notBefore.plus(Duration.ofDays(7)).truncatedTo(SECONDS);
var builder = new CSRBuilder();
builder.addDomains("example.org", "www.example.org");
builder.addIP(InetAddress.getByName("192.0.2.1"));
builder.sign(keypair);
var csr = builder.getCSR();
var cert = CertificateUtils.createTestCertificate(csr, notBefore,
notAfter, rootCert, rootKeypair.getPrivate());
assertThat(cert.getIssuerX500Principal().getName()).isEqualTo(rootSubject);
assertThat(cert.getSubjectX500Principal().getName()).isEqualTo("");
assertThat(getSANs(cert)).contains("example.org", "www.example.org");
assertThat(getIpSANs(cert)).contains(InetAddress.getByName("192.0.2.1"));
assertThat(cert.getNotBefore().toInstant()).isEqualTo(notBefore);
assertThat(cert.getNotAfter().toInstant()).isEqualTo(notAfter);
assertThat(cert.getSerialNumber()).isNotNull();
assertThat(cert.getSerialNumber()).isNotEqualTo(rootCert.getSerialNumber());
assertThat(cert.getPublicKey()).isEqualTo(keypair.getPublic());
cert.verify(rootKeypair.getPublic()); // signed by root
}
/**
* Extracts all DNSName SANs from a certificate.
*
* @param cert
* {@link X509Certificate}
* @return Set of DNSName
*/
private Set getSANs(X509Certificate cert) throws CertificateParsingException {
var result = new HashSet();
for (var list : cert.getSubjectAlternativeNames()) {
if (((Number) list.get(0)).intValue() == GeneralName.dNSName) {
result.add((String) list.get(1));
}
}
return result;
}
/**
* Extracts all IPAddress SANs from a certificate.
*
* @param cert
* {@link X509Certificate}
* @return Set of IPAddresses
*/
private Set getIpSANs(X509Certificate cert) throws CertificateParsingException, UnknownHostException {
var result = new HashSet();
for (var list : cert.getSubjectAlternativeNames()) {
if (((Number) list.get(0)).intValue() == GeneralName.iPAddress) {
result.add(InetAddress.getByName(list.get(1).toString()));
}
}
return result;
}
}
================================================
FILE: acme4j-client/src/test/java/org/shredzone/acme4j/util/KeyPairUtilsTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.util;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.lang.reflect.Modifier;
import java.security.KeyPair;
import java.security.Security;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link KeyPairUtils}.
*/
public class KeyPairUtilsTest {
private static final int KEY_SIZE = 2048;
private static final String EC_CURVE = "secp256r1";
@BeforeAll
public static void setup() {
Security.addProvider(new BouncyCastleProvider());
}
/**
* Test that standard keypair generates a secure key pair.
*/
@Test
public void testCreateStandardKeyPair() {
var pair = KeyPairUtils.createKeyPair();
assertThat(pair).isNotNull();
assertThat(pair.getPublic()).isInstanceOf(ECPublicKey.class);
var pk = (ECPublicKey) pair.getPublic();
assertThat(pk.getAlgorithm()).isEqualTo("ECDSA");
assertThat(pk.getParams().getCurve().getField().getFieldSize()).isEqualTo(384);
}
/**
* Test that RSA keypairs of the correct size are generated.
*/
@Test
public void testCreateKeyPair() {
var pair = KeyPairUtils.createKeyPair(KEY_SIZE);
assertThat(pair).isNotNull();
assertThat(pair.getPublic()).isInstanceOf(RSAPublicKey.class);
var pub = (RSAPublicKey) pair.getPublic();
assertThat(pub.getModulus().bitLength()).isEqualTo(KEY_SIZE);
}
/**
* Test that reading and writing keypairs work correctly.
*/
@Test
public void testWriteAndRead() throws IOException {
// Generate a test keypair
var pair = KeyPairUtils.createKeyPair(KEY_SIZE);
// Write keypair to PEM
String pem;
try (var out = new StringWriter()) {
KeyPairUtils.writeKeyPair(pair, out);
pem = out.toString();
}
// Make sure PEM file is properly formatted
assertThat(pem).matches(
"-----BEGIN RSA PRIVATE KEY-----[\\r\\n]+"
+ "([a-zA-Z0-9/+=]+[\\r\\n]+)+"
+ "-----END RSA PRIVATE KEY-----[\\r\\n]*");
// Read keypair from PEM
KeyPair readPair;
try (var in = new StringReader(pem)) {
readPair = KeyPairUtils.readKeyPair(in);
}
// Verify that both keypairs are the same
assertThat(pair).isNotSameAs(readPair);
assertThat(pair.getPublic().getEncoded()).isEqualTo(readPair.getPublic().getEncoded());
assertThat(pair.getPrivate().getEncoded()).isEqualTo(readPair.getPrivate().getEncoded());
}
/**
* Test that ECDSA keypairs are generated.
*/
@Test
public void testCreateECCKeyPair() {
var pair = KeyPairUtils.createECKeyPair(EC_CURVE);
assertThat(pair).isNotNull();
assertThat(pair.getPublic()).isInstanceOf(ECPublicKey.class);
}
/**
* Test that reading and writing ECDSA keypairs work correctly.
*/
@Test
public void testWriteAndReadEC() throws IOException {
// Generate a test keypair
var pair = KeyPairUtils.createECKeyPair(EC_CURVE);
// Write keypair to PEM
String pem;
try (var out = new StringWriter()) {
KeyPairUtils.writeKeyPair(pair, out);
pem = out.toString();
}
// Make sure PEM file is properly formatted
assertThat(pem).matches(
"-----BEGIN EC PRIVATE KEY-----[\\r\\n]+"
+ "([a-zA-Z0-9/+=]+[\\r\\n]+)+"
+ "-----END EC PRIVATE KEY-----[\\r\\n]*");
// Read keypair from PEM
KeyPair readPair;
try (var in = new StringReader(pem)) {
readPair = KeyPairUtils.readKeyPair(in);
}
// Verify that both keypairs are the same
assertThat(pair).isNotSameAs(readPair);
assertThat(pair.getPublic().getEncoded()).isEqualTo(readPair.getPublic().getEncoded());
assertThat(pair.getPrivate().getEncoded()).isEqualTo(readPair.getPrivate().getEncoded());
// Write Public Key
String publicPem;
try (var out = new StringWriter()) {
KeyPairUtils.writePublicKey(pair.getPublic(), out);
publicPem = out.toString();
}
// Make sure PEM file is properly formatted
assertThat(publicPem).matches(
"-----BEGIN PUBLIC KEY-----[\\r\\n]+"
+ "([a-zA-Z0-9/+=]+[\\r\\n]+)+"
+ "-----END PUBLIC KEY-----[\\r\\n]*");
}
/**
* Test that constructor is private.
*/
@Test
public void testPrivateConstructor() throws Exception {
var constructor = KeyPairUtils.class.getDeclaredConstructor();
assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue();
constructor.setAccessible(true);
constructor.newInstance();
}
}
================================================
FILE: acme4j-client/src/test/resources/.gitignore
================================================
================================================
FILE: acme4j-client/src/test/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeProvider
================================================
# Testing
org.shredzone.acme4j.connector.SessionProviderTest$Provider1
# Testing2
org.shredzone.acme4j.connector.SessionProviderTest$Provider2
================================================
FILE: acme4j-client/src/test/resources/ari-example-cert.pem
================================================
-----BEGIN CERTIFICATE-----
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
-----END CERTIFICATE-----
================================================
FILE: acme4j-client/src/test/resources/cert.pem
================================================
-----BEGIN CERTIFICATE-----
MIIDFzCCAf+gAwIBAgIIYZRPVr9ji5UwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE
AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0NDEz
WhcNMjIwNDI2MTE0NDEzWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBANCRYYYLLZxJeoJKOcSwe+VpwUR/vehv
x1dMy1fZoK3UX9sDcc5kRKxQJ7vog7q6XG4vA4fGcrGAfG6AeuwplWq3kb3UzYeq
JeESeoRG0QhWVwCtIUPPVjHaPS19jP1xaE0vsfzCP3gD4l6W9ZhYlIqirFHEFgK8
aKtMxFsmEVR2cDOyH9S5Eoe7QAY43mcflSV6+BzULRwvtT6ds+0Upf0UMbzp0z8V
dx017MoZdDMAumTaQt8MuIbwxcmRBrZp3pltF3mjGvtBMmuEUoqkiLWtCzhiH2pq
4T9LDBbilZmjgCWB9pLcqe+KxsdgmBSwPVB/3yhvDaAX0ZuvafjEF68CAwEAAaNX
MFUwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAMBgNVHRMBAf8EAjAAMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3
DQEBCwUAA4IBAQAtZKzESSFF9wVUQdjSe+2P+0OFR7vvfnABs0p1fRv3n17OEgwq
iZEui8aUVkY/mzH90rnL25iIUt+7v4PUUIa7NgZ5adxNvnMvTpuQyFYSwfJODFHZ
TZnJQJikvmxa0hIoH+zV0s3Pe3OctNeBEMAu2Tq4KsZZY4hF3c7G0Uwe7vmmffgH
tixADkbOKwqZm1fBzRx6CUjz3u+rmGa4b30unRuF81YI4jqyeOJGNezSYsvLPdIn
p+ISa9mbQvI09bZY/zis0uMGVFcNwKLX3X95xxMONdX7VUsEBq1rFz4ec7priCoi
aEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDFDCCAfygAwIBAgIIZF/FmWNLATcwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE
AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0MzE4
WhcNNDcwNDI2MTE0MzE4WjAoMSYwJAYDVQQDEx1QZWJibGUgSW50ZXJtZWRpYXRl
IENBIDY0NWZjNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMKU292V
U2wr0+8ZyFGZEEziJrqpNPyWPOZtPhjKQt2H8JeAuAnxBDSUjy8h88NKy/wakzIv
v+sYWdfpdezg1Ba331KyN31HWX7AMij35cTBQEx+1rzi+9v2S7woGe2UCuSv6cdz
nJaS0/NOvdDoPSGPctFwOBsCsgx6gr9m5ItanLXMCb8ToKVcUj6GOus0vpB3NNRb
m8sial/o7Sd4cw52riov1mIkR7Pbi6iACGd/KhFxpKAXQ1UMPTd4tZYGU8pCfyiB
0rddSmwhh8eWU5ONShLzHi1aDjiu4NEpRxp8K4Tf0MealIJpyjQf5NV8Dz+QG7aX
DSoH0n+1tGMdMI8CAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB
CwUAA4IBAQBwOTlS9hOo1RhD2/DgBYZl+gYhqjuBedDJfPS3K34n4Z1FVVG+F6IL
zP5CGTIABI3L3Ri1pfgUh2lU5nWfE95gUCnmJf8UA0dp0roJInQ25ux/nKFwcuA/
JL58QZ43TZ/T3BNm8aF/lPvkEut0HnCct1B5IYOzFhqmYS6+BtsiJ2qWxhjiP/yc
CXq3U289glMeSo7mz6FaUEinx6CZL6qHe5Ins/hMo57Jjay32RHjOeFmx+IlCA0o
6kXvrZJy1QUpiUkkV7vbnt/PvQLvKo43YR/MsvuYEiOcPoyt7b7FmZ5VXtCnKBcf
6BcViMAeJ6QzC1qJI6HlWIoqzsO6SKuu
-----END CERTIFICATE-----
================================================
FILE: acme4j-client/src/test/resources/certid-cert.pem
================================================
-----BEGIN CERTIFICATE-----
MIIBQzCB66ADAgECAgUAh2VDITAKBggqhkjOPQQDAjAVMRMwEQYDVQQDEwpFeGFt
cGxlIENBMCIYDzAwMDEwMTAxMDAwMDAwWhgPMDAwMTAxMDEwMDAwMDBaMBYxFDAS
BgNVBAMTC2V4YW1wbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeBZu
7cbpAYNXZLbbh8rNIzuOoqOOtmxA1v7cRm//AwyMwWxyHz4zfwmBhcSrf47NUAFf
qzLQ2PPQxdTXREYEnKMjMCEwHwYDVR0jBBgwFoAUaYhba4dGQEHhs3uEe6CuLN4B
yNQwCgYIKoZIzj0EAwIDRwAwRAIge09+S5TZAlw5tgtiVvuERV6cT4mfutXIlwTb
+FYN/8oCIClDsqBklhB9KAelFiYt9+6FDj3z4KGVelYM5MdsO3pK
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw
MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T
IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6
TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3
zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg
c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA
kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB
AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql
STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq
hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid
1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W
ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg
Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc
fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ
QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ==
-----END CERTIFICATE-----
================================================
FILE: acme4j-client/src/test/resources/json/authorizationChallenges.json
================================================
{
"challenges": [
{
"type": "http-01",
"url": "https://example.com/authz/asdf/0",
"token": "IlirfxKKXAsHtmzK29Pj8A"
},
{
"type": "dns-01",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
},
{
"type": "tls-alpn-01",
"url": "https://example.com/authz/asdf/1",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
},
{
"type": "duplicate-01",
"url": "https://example.com/authz/asdf/3"
},
{
"type": "duplicate-01",
"url": "https://example.com/authz/asdf/4"
}
]
}
================================================
FILE: acme4j-client/src/test/resources/json/canceledOrderResponse.json
================================================
{
"status": "canceled"
}
================================================
FILE: acme4j-client/src/test/resources/json/datatypes.json
================================================
{
"text": "lorem ipsum",
"number": 123,
"boolean": true,
"uri": "mailto:foo@example.com",
"url": "http://example.com",
"date": "2016-01-08T00:00:00Z",
"array": ["foo", 987, [1, 2, 3], {"test": "ok"}],
"collect": ["foo", "bar", "barfoo"],
"status": "VALID",
"binary": "Q2hhaW5zYXc",
"duration": 86400,
"problem": {
"type": "urn:ietf:params:acme:error:rateLimited",
"detail": "too many requests",
"instance": "/documents/errors.html"
},
"encoded": "eyJrZXkiOiJ2YWx1ZSJ9"
}
================================================
FILE: acme4j-client/src/test/resources/json/deactivateAccountResponse.json
================================================
{
"status": "deactivated"
}
================================================
FILE: acme4j-client/src/test/resources/json/directory.json
================================================
{
"newNonce": "https://example.com/acme/new-nonce",
"newAccount": "https://example.com/acme/new-account",
"newOrder": "https://example.com/acme/new-order",
"newAuthz": "https://example.com/acme/new-authz",
"renewalInfo": "https://example.com/acme/renewal-info",
"meta": {
"termsOfService": "https://example.com/acme/terms",
"website": "https://www.example.com/",
"caaIdentities": [
"example.com"
],
"auto-renewal": {
"min-lifetime": 86400,
"max-duration": 31536000,
"allow-certificate-get": true
},
"externalAccountRequired": true,
"subdomainAuthAllowed": true,
"xTestString": "foobar",
"xTestUri": "https://www.example.org",
"xTestArray": [
"foo",
"bar",
"barfoo"
],
"profiles": {
"classic": "The profile you're accustomed to",
"custom": "Some other profile"
}
}
}
================================================
FILE: acme4j-client/src/test/resources/json/directoryNoMeta.json
================================================
{
"newAccount": "https://example.com/acme/new-account",
"newAuthz": "https://example.com/acme/new-authz",
"newOrder": "https://example.com/acme/new-order"
}
================================================
FILE: acme4j-client/src/test/resources/json/dns01Challenge.json
================================================
{
"type": "dns-01",
"url": "https://example.com/acme/authz/0",
"status": "pending",
"token": "pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE"
}
================================================
FILE: acme4j-client/src/test/resources/json/dnsAccount01Challenge.json
================================================
{
"type": "dns-account-01",
"url": "https://example.com/acme/chall/i00MGYwLWIx",
"status": "pending",
"token": "ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx"
}
================================================
FILE: acme4j-client/src/test/resources/json/dnsPersist01Challenge.json
================================================
{
"type": "dns-persist-01",
"url": "https://ca.example/acme/authz/1234/0",
"status": "pending",
"accounturi": "https://example.com/acme/account/1",
"issuer-domain-names": ["authority.example", "ca.example.net"]
}
================================================
FILE: acme4j-client/src/test/resources/json/finalizeAutoRenewResponse.json
================================================
{
"status": "valid",
"expires": "2015-03-01T14:09:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.com"
},
{
"type": "dns",
"value": "www.example.com"
}
],
"auto-renewal": {
"start-date": "2018-01-01T00:00:00Z",
"end-date": "2019-01-01T00:00:00Z",
"lifetime": 604800,
"lifetime-adjust": 518400,
"allow-certificate-get": true
},
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize",
"star-certificate": "https://example.com/acme/cert/1234"
}
================================================
FILE: acme4j-client/src/test/resources/json/finalizeRequest.json
================================================
{
"csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb"
}
================================================
FILE: acme4j-client/src/test/resources/json/finalizeResponse.json
================================================
{
"status": "valid",
"expires": "2015-03-01T14:09:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.com"
},
{
"type": "dns",
"value": "www.example.com"
}
],
"notBefore": "2016-01-01T00:00:00Z",
"notAfter": "2016-01-08T00:00:00Z",
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize",
"certificate": "https://example.com/acme/cert/1234"
}
================================================
FILE: acme4j-client/src/test/resources/json/genericChallenge.json
================================================
{
"type": "generic-01",
"status": "invalid",
"url": "http://example.com/challenge/123",
"validated": "2015-12-12T17:19:36.336785823Z",
"error": {
"type": "urn:ietf:params:acme:error:incorrectResponse",
"detail": "bad token",
"instance": "/documents/faq.html"
}
}
================================================
FILE: acme4j-client/src/test/resources/json/httpChallenge.json
================================================
{
"type": "http-01",
"url": "https://example.com/acme/authz/0",
"status": "pending",
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"
}
================================================
FILE: acme4j-client/src/test/resources/json/httpNoTokenChallenge.json
================================================
{
"type": "http-01",
"url": "https://example.com/acme/authz/0",
"status": "pending"
}
================================================
FILE: acme4j-client/src/test/resources/json/modifyAccount.json
================================================
{
"contact": [
"mailto:foo@example.com",
"mailto:foo2@example.com",
"mailto:foo3@example.com"
]
}
================================================
FILE: acme4j-client/src/test/resources/json/modifyAccountResponse.json
================================================
{
"termsOfServiceAgreed": true,
"contact": [
"mailto:foo@example.com",
"mailto:foo2@example.com",
"mailto:foo3@example.com"
]
}
================================================
FILE: acme4j-client/src/test/resources/json/newAccount.json
================================================
{
"termsOfServiceAgreed": true,
"contact": [
"mailto:foo@example.com"
]
}
================================================
FILE: acme4j-client/src/test/resources/json/newAccountOnlyExisting.json
================================================
{
"onlyReturnExisting": true
}
================================================
FILE: acme4j-client/src/test/resources/json/newAccountResponse.json
================================================
{
"status": "valid",
"termsOfServiceAgreed": true,
"contact": [
"mailto:foo@example.com"
],
"orders": "https://example.com/acme/orders/rzGoeA"
}
================================================
FILE: acme4j-client/src/test/resources/json/newAuthorizationRequest.json
================================================
{
"identifier": {
"type": "dns",
"value": "example.org"
}
}
================================================
FILE: acme4j-client/src/test/resources/json/newAuthorizationRequestSub.json
================================================
{
"identifier": {
"type": "dns",
"value": "example.org",
"subdomainAuthAllowed": true
}
}
================================================
FILE: acme4j-client/src/test/resources/json/newAuthorizationResponse.json
================================================
{
"status": "pending",
"identifier": {
"type": "dns",
"value": "example.org"
},
"challenges": [
{
"type": "http-01",
"status": "pending",
"url": "https://example.com/authz/asdf/0",
"token": "IlirfxKKXAsHtmzK29Pj8A"
},
{
"type": "dns-01",
"status": "pending",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
}
]
}
================================================
FILE: acme4j-client/src/test/resources/json/newAuthorizationResponseSub.json
================================================
{
"status": "pending",
"identifier": {
"type": "dns",
"value": "example.org"
},
"challenges": [
{
"type": "dns-01",
"status": "pending",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
}
],
"subdomainAuthAllowed": true
}
================================================
FILE: acme4j-client/src/test/resources/json/problem.json
================================================
{
"type": "urn:ietf:params:acme:error:malformed",
"title": "Some of the identifiers requested were rejected",
"detail": "Identifier \"abc12_\" is malformed",
"instance": "/documents/error.html",
"subproblems": [
{
"type": "urn:ietf:params:acme:error:malformed",
"detail": "Invalid underscore in DNS name \"_example.com\"",
"identifier": {
"type": "dns",
"value": "_example.com"
}
},
{
"type": "urn:ietf:params:acme:error:rejectedIdentifier",
"detail": "This CA will not issue for \"example.net\"",
"identifier": {
"type": "dns",
"value": "example.net"
}
}
]
}
================================================
FILE: acme4j-client/src/test/resources/json/renewalInfo.json
================================================
{
"suggestedWindow": {
"start": "2021-01-03T00:00:00Z",
"end": "2021-01-07T00:00:00Z"
},
"explanationURL": "https://example.com/docs/example-mass-reissuance-event"
}
================================================
FILE: acme4j-client/src/test/resources/json/replacedCertificateRequest.json
================================================
{
"certID": "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c",
"replaced": true
}
================================================
FILE: acme4j-client/src/test/resources/json/requestAutoRenewOrderRequest.json
================================================
{
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"auto-renewal": {
"start-date": "2018-01-01T00:00:00Z",
"end-date": "2019-01-01T00:00:00Z",
"lifetime": 604800,
"lifetime-adjust": 518400,
"allow-certificate-get": true
}
}
================================================
FILE: acme4j-client/src/test/resources/json/requestAutoRenewOrderResponse.json
================================================
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"auto-renewal": {
"start-date": "2018-01-01T00:00:00Z",
"end-date": "2019-01-01T00:00:00Z",
"lifetime": 604800,
"lifetime-adjust": 518400,
"allow-certificate-get": true
},
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestCertificateRequest.json
================================================
{
"csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestCertificateRequestWithDate.json
================================================
{
"csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb",
"notBefore": "2016-01-01T00:00:00Z",
"notAfter": "2016-01-08T00:00:00Z"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestOrderRequest.json
================================================
{
"identifiers": [
{
"type": "dns",
"value": "example.com"
},
{
"type": "dns",
"value": "www.example.com"
},
{
"type": "dns",
"value": "example.org"
},
{
"type": "dns",
"value": "m.example.com"
},
{
"type": "dns",
"value": "m.example.org"
},
{
"type": "dns",
"value": "d.example.com"
},
{
"type": "dns",
"value": "d2.example.com"
},
{
"type": "ip",
"value": "192.0.2.2"
}
],
"notBefore": "2016-01-01T00:00:00Z",
"notAfter": "2016-01-08T00:00:00Z"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestOrderRequestSub.json
================================================
{
"identifiers": [
{
"type": "dns",
"value": "foo.bar.example.com",
"ancestorDomain": "example.com"
},
],
"notBefore": "2016-01-01T00:00:00Z",
"notAfter": "2016-01-08T00:00:00Z"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestOrderResponse.json
================================================
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.com"
},
{
"type": "dns",
"value": "www.example.com"
},
{
"type": "dns",
"value": "example.org"
},
{
"type": "dns",
"value": "m.example.com"
},
{
"type": "dns",
"value": "m.example.org"
},
{
"type": "dns",
"value": "d.example.com"
},
{
"type": "dns",
"value": "d2.example.com"
},
{
"type": "ip",
"value": "192.0.2.2"
}
],
"notBefore": "2016-01-01T00:10:00Z",
"notAfter": "2016-01-08T00:10:00Z",
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestOrderResponseSub.json
================================================
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "foo.bar.example.com"
}
],
"notBefore": "2016-01-01T00:10:00Z",
"notAfter": "2016-01-08T00:10:00Z",
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestProfileOrderRequest.json
================================================
{
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"profile": "classic"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestProfileOrderResponse.json
================================================
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize",
"profile": "classic"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestReplacesRequest.json
================================================
{
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"replaces": "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
}
================================================
FILE: acme4j-client/src/test/resources/json/requestReplacesResponse.json
================================================
{
"status": "pending",
"expires": "2016-01-10T00:00:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.org"
}
],
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize"
}
================================================
FILE: acme4j-client/src/test/resources/json/revokeCertificateRequest.json
================================================
{
"certificate": "MIIDFzCCAf-gAwIBAgIIYZRPVr9ji5UwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0NDEzWhcNMjIwNDI2MTE0NDEzWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANCRYYYLLZxJeoJKOcSwe-VpwUR_vehvx1dMy1fZoK3UX9sDcc5kRKxQJ7vog7q6XG4vA4fGcrGAfG6AeuwplWq3kb3UzYeqJeESeoRG0QhWVwCtIUPPVjHaPS19jP1xaE0vsfzCP3gD4l6W9ZhYlIqirFHEFgK8aKtMxFsmEVR2cDOyH9S5Eoe7QAY43mcflSV6-BzULRwvtT6ds-0Upf0UMbzp0z8Vdx017MoZdDMAumTaQt8MuIbwxcmRBrZp3pltF3mjGvtBMmuEUoqkiLWtCzhiH2pq4T9LDBbilZmjgCWB9pLcqe-KxsdgmBSwPVB_3yhvDaAX0ZuvafjEF68CAwEAAaNXMFUwDgYDVR0PAQH_BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAtZKzESSFF9wVUQdjSe-2P-0OFR7vvfnABs0p1fRv3n17OEgwqiZEui8aUVkY_mzH90rnL25iIUt-7v4PUUIa7NgZ5adxNvnMvTpuQyFYSwfJODFHZTZnJQJikvmxa0hIoH-zV0s3Pe3OctNeBEMAu2Tq4KsZZY4hF3c7G0Uwe7vmmffgHtixADkbOKwqZm1fBzRx6CUjz3u-rmGa4b30unRuF81YI4jqyeOJGNezSYsvLPdInp-ISa9mbQvI09bZY_zis0uMGVFcNwKLX3X95xxMONdX7VUsEBq1rFz4ec7priCoiaEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4"
}
================================================
FILE: acme4j-client/src/test/resources/json/revokeCertificateWithReasonRequest.json
================================================
{
"certificate": "MIIDFzCCAf-gAwIBAgIIYZRPVr9ji5UwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0NDEzWhcNMjIwNDI2MTE0NDEzWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANCRYYYLLZxJeoJKOcSwe-VpwUR_vehvx1dMy1fZoK3UX9sDcc5kRKxQJ7vog7q6XG4vA4fGcrGAfG6AeuwplWq3kb3UzYeqJeESeoRG0QhWVwCtIUPPVjHaPS19jP1xaE0vsfzCP3gD4l6W9ZhYlIqirFHEFgK8aKtMxFsmEVR2cDOyH9S5Eoe7QAY43mcflSV6-BzULRwvtT6ds-0Upf0UMbzp0z8Vdx017MoZdDMAumTaQt8MuIbwxcmRBrZp3pltF3mjGvtBMmuEUoqkiLWtCzhiH2pq4T9LDBbilZmjgCWB9pLcqe-KxsdgmBSwPVB_3yhvDaAX0ZuvafjEF68CAwEAAaNXMFUwDgYDVR0PAQH_BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAtZKzESSFF9wVUQdjSe-2P-0OFR7vvfnABs0p1fRv3n17OEgwqiZEui8aUVkY_mzH90rnL25iIUt-7v4PUUIa7NgZ5adxNvnMvTpuQyFYSwfJODFHZTZnJQJikvmxa0hIoH-zV0s3Pe3OctNeBEMAu2Tq4KsZZY4hF3c7G0Uwe7vmmffgHtixADkbOKwqZm1fBzRx6CUjz3u-rmGa4b30unRuF81YI4jqyeOJGNezSYsvLPdInp-ISa9mbQvI09bZY_zis0uMGVFcNwKLX3X95xxMONdX7VUsEBq1rFz4ec7priCoiaEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4",
"reason": 1
}
================================================
FILE: acme4j-client/src/test/resources/json/tlsAlpnChallenge.json
================================================
{
"type": "tls-alpn-01",
"url": "https://example.com/acme/authz/0",
"status": "pending",
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"
}
================================================
FILE: acme4j-client/src/test/resources/json/triggerHttpChallenge.json
================================================
{
"type": "http-01",
"status": "pending",
"url": "https://example.com/acme/some-location",
"token": "IlirfxKKXAsHtmzK29Pj8A"
}
================================================
FILE: acme4j-client/src/test/resources/json/triggerHttpChallengeRequest.json
================================================
{
}
================================================
FILE: acme4j-client/src/test/resources/json/triggerHttpChallengeResponse.json
================================================
{
"type": "http-01",
"status": "pending",
"url": "https://example.com/acme/some-location",
"token": "IlirfxKKXAsHtmzK29Pj8A"
}
================================================
FILE: acme4j-client/src/test/resources/json/updateAccount.json
================================================
{}
================================================
FILE: acme4j-client/src/test/resources/json/updateAccountResponse.json
================================================
{
"status": "valid",
"contact": [
"mailto:foo2@example.com"
],
"termsOfServiceAgreed": true,
"orders": "https://example.com/acme/acct/1/orders",
"externalAccountBinding": {
"protected": "eyJ1cmwiOiJodHRwOi8vZXhhbXBsZS5jb20vYWNtZS9yZXNvdXJjZSIsImtpZCI6Ik5DQy0xNzAxIiwiYWxnIjoiSFMyNTYifQ",
"payload": "eyJrdHkiOiJSU0EiLCJuIjoicFpzVEtZNDF5X0N3Z0owVlg3Qm1tR3NfN1Vwcm1YUU1HUGNuU2JCZUpBalpIQTlTeXlKS2FXdjRmTlVkQklBWDNZMlFvWml4ajUwblFMeUx2Mm5nM3B2RW9STDBzeDlaSGdwNW5kQWpwSWlWUV84VjAxVFRZQ0VEVWM5aWk3YmpWa2dGQWI0VmFsWkdGSlo1NFBjQ25BSHZYaTVnMEVMT1J6R2NUdVJxSFZBVWNrTVYyb3RyMGcwdV81YldNbTZFTUFiQnJHUUNnVUdqYlpRSGphdmExWS01dEhYWmtQQmFoSjJMdktScU1tSlVscjBhbkt1Skp0SlVHMDNESllBeEFCdjhZQWFYRkJuR3c2a0tKUnBVRkFDNTVyeTRzcDRrR3kwTnJLMlRWV21aVzlrU3RuaVJ2NFJhSkdJOWFaR1l3UXkya1V5a2liQk5tV0VRVWxJd0l3IiwiZSI6IkFRQUIifQ",
"signature": "skPdpjTgx8zIGsNRtvv4zNlfp-uidFDgCMY3Z3ONLgw"
}
}
================================================
FILE: acme4j-client/src/test/resources/json/updateAuthorizationResponse.json
================================================
{
"status": "valid",
"expires": "2016-01-02T17:12:40Z",
"identifier": {
"type": "dns",
"value": "example.org"
},
"challenges": [
{
"type": "http-01",
"status": "pending",
"url": "https://example.com/authz/asdf/0",
"token": "IlirfxKKXAsHtmzK29Pj8A"
},
{
"type": "dns-01",
"status": "pending",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
},
{
"type": "tls-alpn-01",
"status": "pending",
"url": "https://example.com/authz/asdf/2",
"token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
}
]
}
================================================
FILE: acme4j-client/src/test/resources/json/updateAuthorizationWildcardResponse.json
================================================
{
"status": "valid",
"expires": "2016-01-02T17:12:40Z",
"wildcard": true,
"identifier": {
"type": "dns",
"value": "example.org"
},
"challenges": [
{
"type": "dns-01",
"status": "pending",
"url": "https://example.com/authz/asdf/1",
"token": "DGyRejmCefe7v4NfDGDKfA"
}
]
}
================================================
FILE: acme4j-client/src/test/resources/json/updateAutoRenewOrderResponse.json
================================================
{
"status": "valid",
"auto-renewal": {
"start-date": "2016-01-01T00:00:00Z",
"end-date": "2017-01-01T00:00:00Z",
"lifetime": 604800,
"lifetime-adjust": 518400,
"allow-certificate-get": true
}
}
================================================
FILE: acme4j-client/src/test/resources/json/updateHttpChallengeResponse.json
================================================
{
"type": "http-01",
"url": "https://example.com/acme/some-location",
"status": "valid",
"token": "IlirfxKKXAsHtmzK29Pj8A",
"keyAuthorization": "XbmEGDDc2AMDArHLt5x7GxZfIRv0aScknUKlyf5S4KU.KMH_h8aGAKlY3VQqBUczm1cfo9kaovivy59rSY1xZ0E"
}
================================================
FILE: acme4j-client/src/test/resources/json/updateOrderResponse.json
================================================
{
"status": "pending",
"expires": "2015-03-01T14:09:00Z",
"identifiers": [
{
"type": "dns",
"value": "example.com"
},
{
"type": "dns",
"value": "www.example.com"
}
],
"notBefore": "2016-01-01T00:00:00Z",
"notAfter": "2016-01-08T00:00:00Z",
"authorizations": [
"https://example.com/acme/authz/1234",
"https://example.com/acme/authz/2345"
],
"finalize": "https://example.com/acme/acct/1/order/1/finalize",
"certificate": "https://example.com/acme/cert/1234",
"error": {
"type": "urn:ietf:params:acme:error:connection",
"detail": "connection refused"
}
}
================================================
FILE: acme4j-client/src/test/resources/simplelogger.properties
================================================
org.slf4j.simpleLogger.log.org.shredzone.acme4j = debug
================================================
FILE: acme4j-example/.gitignore
================================================
*.key
*.crt
*.csr
================================================
FILE: acme4j-example/pom.xml
================================================
4.0.0
org.shredzone.acme4j
acme4j
5.1.1-SNAPSHOT
acme4j-example
acme4j Example
Example for using acme4j
true
org.codehaus.mojo
exec-maven-plugin
3.0.0
java
org.shredzone.acme4j.example.ClientTest
org.shredzone.acme4j
acme4j-client
${project.version}
org.slf4j
slf4j-simple
${slf4j.version}
runtime
================================================
FILE: acme4j-example/src/main/java/.gitignore
================================================
================================================
FILE: acme4j-example/src/main/java/module-info.java
================================================
/*
* 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.
*/
module org.shredzone.acme4j.example {
requires org.shredzone.acme4j;
requires java.desktop;
requires org.bouncycastle.provider;
requires org.slf4j;
}
================================================
FILE: acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java
================================================
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 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.example;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.security.KeyPair;
import java.security.Security;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.Optional;
import java.util.function.Supplier;
import javax.swing.JOptionPane;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.shredzone.acme4j.Account;
import org.shredzone.acme4j.AccountBuilder;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Certificate;
import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.KeyPairUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A simple client test tool.
*
* First check the configuration constants at the top of the class. Then run the class,
* and pass in the names of the domains as parameters.
*
* The tool won't run as-is. You MUST change the {@link #CA_URI} constant and set the
* connection URI of your target CA there.
*
* If your CA requires External Account Binding (EAB), you MUST also fill the
* {@link #EAB_KID} and {@link #EAB_HMAC} constants with the values provided by your CA.
*
* If your CA requires an email field to be set in your account, you also need to set
* {@link #ACCOUNT_EMAIL}.
*
* All other fields are optional and should work with the default values, unless your CA
* has special requirements (e.g. to the key type).
*
* @see This example, fully
* explained in the documentation.
*/
public class ClientTest {
// Set the Connection URI of your CA here. For testing purposes, use a staging
// server if possible. Example: "acme://letsencrypt.org/staging" for the Let's
// Encrypt staging server.
private static final String CA_URI = "acme://example.com/staging";
// E-Mail address to be associated with the account. Optional, null if not used.
private static final String ACCOUNT_EMAIL = null;
// If the CA requires External Account Binding (EAB), set the provided KID and HMAC here.
private static final String EAB_KID = null;
private static final String EAB_HMAC = null;
// A supplier for a new account KeyPair. The default creates a new EC key pair.
private static final Supplier ACCOUNT_KEY_SUPPLIER = KeyPairUtils::createKeyPair;
// A supplier for a new domain KeyPair. The default creates a RSA key pair.
private static final Supplier DOMAIN_KEY_SUPPLIER = () -> KeyPairUtils.createKeyPair(4096);
// File name of the User Key Pair
private static final File USER_KEY_FILE = new File("user.key");
// File name of the Domain Key Pair
private static final File DOMAIN_KEY_FILE = new File("domain.key");
// File name of the signed certificate
private static final File DOMAIN_CHAIN_FILE = new File("domain-chain.crt");
//Challenge type to be used
private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
// Maximum time to wait until VALID/INVALID is expected
private static final Duration TIMEOUT = Duration.ofSeconds(60L);
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
private enum ChallengeType {HTTP, DNS}
/**
* Generates a certificate for the given domains. Also takes care for the registration
* process.
*
* @param domains
* Domains to get a common certificate for
*/
public void fetchCertificate(Collection domains) throws IOException, AcmeException, InterruptedException {
// Load the user key file. If there is no key file, create a new one.
KeyPair userKeyPair = loadOrCreateUserKeyPair();
// Create a session.
Session session = new Session(CA_URI);
// Get the Account.
// If there is no account yet, create a new one.
Account acct = findOrRegisterAccount(session, userKeyPair);
// Load or create a key pair for the domains. This should not be the userKeyPair!
KeyPair domainKeyPair = loadOrCreateDomainKeyPair();
// Order the certificate
Order order = acct.newOrder().domains(domains).create();
// Perform all required authorizations
for (Authorization auth : order.getAuthorizations()) {
authorize(auth);
}
// Wait for the order to become READY
order.waitUntilReady(TIMEOUT);
// Order the certificate
order.execute(domainKeyPair);
// Wait for the order to complete
Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString)
.orElse("unknown"));
throw new AcmeException("Order failed... Giving up.");
}
// Get the certificate
Certificate certificate = order.getCertificate();
LOG.info("Success! The certificate for domains {} has been generated!", domains);
LOG.info("Certificate URL: {}", certificate.getLocation());
// Write a combined file containing the certificate and chain.
try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
certificate.writeCertificate(fw);
}
// That's all! Configure your web server to use the DOMAIN_KEY_FILE and
// DOMAIN_CHAIN_FILE for the requested domains.
}
/**
* Loads a user key pair from {@link #USER_KEY_FILE}. If the file does not exist, a
* new key pair is generated and saved.
*
* Keep this key pair in a safe place! In a production environment, you will not be
* able to access your account again if you should lose the key pair.
*
* @return User's {@link KeyPair}.
*/
private KeyPair loadOrCreateUserKeyPair() throws IOException {
if (USER_KEY_FILE.exists()) {
// If there is a key file, read it
try (FileReader fr = new FileReader(USER_KEY_FILE)) {
return KeyPairUtils.readKeyPair(fr);
}
} else {
// If there is none, create a new key pair and save it
KeyPair userKeyPair = ACCOUNT_KEY_SUPPLIER.get();
try (FileWriter fw = new FileWriter(USER_KEY_FILE)) {
KeyPairUtils.writeKeyPair(userKeyPair, fw);
}
return userKeyPair;
}
}
/**
* Loads a domain key pair from {@link #DOMAIN_KEY_FILE}. If the file does not exist,
* a new key pair is generated and saved.
*
* @return Domain {@link KeyPair}.
*/
private KeyPair loadOrCreateDomainKeyPair() throws IOException {
if (DOMAIN_KEY_FILE.exists()) {
try (FileReader fr = new FileReader(DOMAIN_KEY_FILE)) {
return KeyPairUtils.readKeyPair(fr);
}
} else {
KeyPair domainKeyPair = DOMAIN_KEY_SUPPLIER.get();
try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) {
KeyPairUtils.writeKeyPair(domainKeyPair, fw);
}
return domainKeyPair;
}
}
/**
* Finds your {@link Account} at the ACME server. It will be found by your user's
* public key. If your key is not known to the server yet, a new account will be
* created.
*
* This is a simple way of finding your {@link Account}. A better way is to get the
* URL of your new account with {@link Account#getLocation()} and store it somewhere.
* If you need to get access to your account later, reconnect to it via {@link
* Session#login(URL, KeyPair)} by using the stored location.
*
* @param session
* {@link Session} to bind with
* @return {@link Account}
*/
private Account findOrRegisterAccount(Session session, KeyPair accountKey) throws AcmeException {
// Ask the user to accept the TOS, if server provides us with a link.
Optional tos = session.getMetadata().getTermsOfService();
if (tos.isPresent()) {
acceptAgreement(tos.get());
}
AccountBuilder accountBuilder = new AccountBuilder()
.agreeToTermsOfService()
.useKeyPair(accountKey);
// Set your email (if available)
if (ACCOUNT_EMAIL != null) {
accountBuilder.addEmail(ACCOUNT_EMAIL);
}
// Use the KID and HMAC if the CA uses External Account Binding
if (EAB_KID != null && EAB_HMAC != null) {
accountBuilder.withKeyIdentifier(EAB_KID, EAB_HMAC);
}
Account account = accountBuilder.create(session);
LOG.info("Registered a new user, URL: {}", account.getLocation());
return account;
}
/**
* Authorize a domain. It will be associated with your account, so you will be able to
* retrieve a signed certificate for the domain later.
*
* @param auth
* {@link Authorization} to perform
*/
private void authorize(Authorization auth) throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid. No need to process a challenge.
if (auth.getStatus() == Status.VALID) {
return;
}
// Find the desired challenge and prepare it.
Challenge challenge = switch (CHALLENGE_TYPE) {
case HTTP -> httpChallenge(auth);
case DNS -> dnsChallenge(auth);
};
if (challenge == null) {
throw new AcmeException("No challenge found");
}
// If the challenge is already verified, there's no need to execute it again.
if (challenge.getStatus() == Status.VALID) {
return;
}
// Now trigger the challenge.
challenge.trigger();
// Poll for the challenge to complete.
Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString)
.orElse("unknown"));
throw new AcmeException("Challenge failed... Giving up.");
}
LOG.info("Challenge has been completed. Remember to remove the validation resource.");
completeChallenge("Challenge has been completed.\nYou can remove the resource again now.");
}
/**
* Prepares a HTTP challenge.
*
* The verification of this challenge expects a file with a certain content to be
* reachable at a given path under the domain to be tested.
*
* This example outputs instructions that need to be executed manually. In a
* production environment, you would rather generate this file automatically, or maybe
* use a servlet that returns {@link Http01Challenge#getAuthorization()}.
*
* @param auth
* {@link Authorization} to find the challenge in
* @return {@link Challenge} to verify
*/
public Challenge httpChallenge(Authorization auth) throws AcmeException {
// Find a single http-01 challenge
Http01Challenge challenge = auth.findChallenge(Http01Challenge.class)
.orElseThrow(() -> new AcmeException("Found no " + Http01Challenge.TYPE
+ " challenge, don't know what to do..."));
// Output the challenge, wait for acknowledge...
LOG.info("Please create a file in your web server's base directory.");
LOG.info("It must be reachable at: http://{}/.well-known/acme-challenge/{}",
auth.getIdentifier().getDomain(), challenge.getToken());
LOG.info("File name: {}", challenge.getToken());
LOG.info("Content: {}", challenge.getAuthorization());
LOG.info("The file must not contain any leading or trailing whitespaces or line breaks!");
LOG.info("If you're ready, dismiss the dialog...");
StringBuilder message = new StringBuilder();
message.append("Please create a file in your web server's base directory.\n\n");
message.append("http://")
.append(auth.getIdentifier().getDomain())
.append("/.well-known/acme-challenge/")
.append(challenge.getToken())
.append("\n\n");
message.append("Content:\n\n");
message.append(challenge.getAuthorization());
acceptChallenge(message.toString());
return challenge;
}
/**
* Prepares a DNS challenge.
*
* The verification of this challenge expects a TXT record with a certain content.
*
* This example outputs instructions that need to be executed manually. In a
* production environment, you would rather configure your DNS automatically.
*
* @param auth
* {@link Authorization} to find the challenge in
* @return {@link Challenge} to verify
*/
public Challenge dnsChallenge(Authorization auth) throws AcmeException {
// Find a single dns-01 challenge
Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.TYPE)
.map(Dns01Challenge.class::cast)
.orElseThrow(() -> new AcmeException("Found no " + Dns01Challenge.TYPE
+ " challenge, don't know what to do..."));
// Output the challenge, wait for acknowledge...
LOG.info("Please create a TXT record:");
LOG.info("{} IN TXT {}",
challenge.getRRName(auth.getIdentifier()), challenge.getDigest());
LOG.info("If you're ready, dismiss the dialog...");
StringBuilder message = new StringBuilder();
message.append("Please create a TXT record:\n\n");
message.append(challenge.getRRName(auth.getIdentifier()))
.append(" IN TXT ")
.append(challenge.getDigest());
acceptChallenge(message.toString());
return challenge;
}
/**
* Presents the instructions for preparing the challenge validation, and waits for
* dismissal. If the user cancelled the dialog, an exception is thrown.
*
* @param message
* Instructions to be shown in the dialog
*/
public void acceptChallenge(String message) throws AcmeException {
int option = JOptionPane.showConfirmDialog(null,
message,
"Prepare Challenge",
JOptionPane.OK_CANCEL_OPTION);
if (option == JOptionPane.CANCEL_OPTION) {
throw new AcmeException("User cancelled the challenge");
}
}
/**
* Presents the instructions for removing the challenge validation, and waits for
* dismissal.
*
* @param message
* Instructions to be shown in the dialog
*/
public void completeChallenge(String message) {
JOptionPane.showMessageDialog(null,
message,
"Complete Challenge",
JOptionPane.INFORMATION_MESSAGE);
}
/**
* Presents the user a link to the Terms of Service, and asks for confirmation. If the
* user denies confirmation, an exception is thrown.
*
* @param agreement
* {@link URI} of the Terms of Service
*/
public void acceptAgreement(URI agreement) throws AcmeException {
int option = JOptionPane.showConfirmDialog(null,
"Do you accept the Terms of Service?\n\n" + agreement,
"Accept ToS",
JOptionPane.YES_NO_OPTION);
if (option == JOptionPane.NO_OPTION) {
throw new AcmeException("User did not accept Terms of Service");
}
}
/**
* Invokes this example.
*
* @param args
* Domains to get a certificate for
*/
public static void main(String... args) {
if (args.length == 0) {
System.err.println("Usage: ClientTest ...");
System.exit(1);
}
LOG.info("Starting up...");
Security.addProvider(new BouncyCastleProvider());
Collection domains = Arrays.asList(args);
try {
ClientTest ct = new ClientTest();
ct.fetchCertificate(domains);
} catch (Exception ex) {
LOG.error("Failed to get a certificate for domains " + domains, ex);
}
}
}
================================================
FILE: acme4j-example/src/main/resources/.gitignore
================================================
================================================
FILE: acme4j-example/src/main/resources/simplelogger.properties
================================================
org.slf4j.simpleLogger.log.org.shredzone.acme4j = debug
================================================
FILE: acme4j-example/src/test/java/.gitignore
================================================
================================================
FILE: acme4j-example/src/test/resources/.gitignore
================================================
================================================
FILE: acme4j-it/pom.xml
================================================