Changed the way the acme client works

This commit is contained in:
Ziver Koc 2021-08-20 23:09:24 +02:00
parent 708577debf
commit 98f53adcfb
2 changed files with 97 additions and 43 deletions

View file

@ -6,17 +6,14 @@ import java.net.URL;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.Security; import java.security.Security;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.shredzone.acme4j.Account; import org.shredzone.acme4j.*;
import org.shredzone.acme4j.AccountBuilder;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Certificate;
import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.CSRBuilder; import org.shredzone.acme4j.util.CSRBuilder;
@ -44,69 +41,107 @@ public class AcmeClient {
private AcmeDataStore dataStore; private AcmeDataStore dataStore;
private AcmeChallengeFactory challengeFactory; private AcmeChallengeFactory challengeFactory;
private Account acmeAccount;
private ArrayList<String> domains = new ArrayList<>();
private Order order;
private ArrayList<Challenge> challenges = new ArrayList<>();
public AcmeClient(AcmeDataStore dataStore, HttpServer httpServer) {
public AcmeClient(AcmeDataStore dataStore, HttpServer httpServer) throws AcmeException {
this(dataStore, new AcmeHttpChallengeFactory(httpServer), ACME_SERVER_LETSENCRYPT_PRODUCTION); this(dataStore, new AcmeHttpChallengeFactory(httpServer), ACME_SERVER_LETSENCRYPT_PRODUCTION);
} }
public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory) { public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory) throws AcmeException {
this(dataStore, challengeFactory, ACME_SERVER_LETSENCRYPT_PRODUCTION); this(dataStore, challengeFactory, ACME_SERVER_LETSENCRYPT_PRODUCTION);
} }
/** /**
* Create a new instance of the ACME Client * Create a new instance of the ACME Client and authenticates the user account towards
* the AMCE service, if no account exists then a new one will be created.
*/ */
public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory, String acmeServerUrl) { public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory, String acmeServerUrl) throws AcmeException {
Security.addProvider(new BouncyCastleProvider()); Security.addProvider(new BouncyCastleProvider());
this.dataStore = dataStore; this.dataStore = dataStore;
this.challengeFactory = challengeFactory; this.challengeFactory = challengeFactory;
this.acmeServerUrl = acmeServerUrl; this.acmeServerUrl = acmeServerUrl;
}
/**
* Generates a certificate for the given domains. Also takes care for the registration
* process.
*
* @param domains the domains to get a certificates for
* @return a certificate for the given domains.
*/
public X509Certificate fetchCertificate(String... domains) throws IOException, AcmeException {
// ------------------------------------------------ // ------------------------------------------------
// Read in keys // Read in keys
// ------------------------------------------------ // ------------------------------------------------
KeyPair userKeyPair = dataStore.loadUserKeyPair(); // Load the user key file. If there is no key file, create a new one. KeyPair userKeyPair = dataStore.loadUserKeyPair(); // Load the user key file. If there is no key file, create a new one.
KeyPair domainKeyPair = dataStore.loadDomainKeyPair(); // Load or create a key pair for the domains. This should not be the userKeyPair! KeyPair domainKeyPair = dataStore.loadDomainKeyPair(); // Load or create a key pair for the domains. This should not be same as the userKeyPair!
if (userKeyPair == null) { if (userKeyPair == null) {
logger.fine("Creating new user keys.");
userKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); userKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
dataStore.storeUserKeyPair(userKeyPair); dataStore.storeUserKeyPair(userKeyPair);
} }
if (domainKeyPair == null) { if (domainKeyPair == null) {
logger.fine("Creating new domain keys.");
domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
dataStore.storeDomainKeyPair(domainKeyPair); dataStore.storeDomainKeyPair(domainKeyPair);
} }
// ------------------------------------------------ // ------------------------------------------------
// Start authorization process // Start user authorization process
// ------------------------------------------------ // ------------------------------------------------
Session session = new Session(acmeServerUrl); Session session = new Session(acmeServerUrl);
Account acct = getAccount(session, userKeyPair); // Get the Account. If there is no account yet, create a new one. acmeAccount = getAccount(session, userKeyPair); // Get the Account. If there is no account yet, create a new one.
Order order = acct.newOrder().domains(domains).create(); // Order the certificate }
/**
* Add a domain that the certificate should be valid for. This method can only be called before
* the {@link #prepareRequest()} method has been called.
*
* @param domains the domains to add to the certificate
*/
public void addDomain(String... domains) {
Collections.addAll(this.domains, domains);
}
/**
* This method will prepare the request for the ACME service. Any manual action must be taken after this
* method has been called and before the {@link #requestCertificate()} method is called.
*
* @throws AcmeException
*/
public void prepareRequest() throws AcmeException {
order = acmeAccount.newOrder().domains(domains).create(); // Order the certificate
// Perform all required authorizations // Perform all required authorizations
for (Authorization auth : order.getAuthorizations()) { for (Authorization auth : order.getAuthorizations()) {
if (auth.getStatus() == Status.VALID) if (auth.getStatus() == Status.VALID)
continue; // The authorization is already valid. No need to process a challenge. continue; // The authorization is already valid. No need to process a challenge.
execDomainChallenge(auth); challenges.add(challengeFactory.createChallenge(auth));
}
} }
// Generate a "Certificate Signing Request" for all of the domains, and sign it with the domain key pair. /**
* Connects to the ACME service and requests a certificate to be generated.
* <p>
* Note that before this method is called the {@link #prepareRequest()} method must be called to prepare
* the challenge requests. The reason for this is as some challenges require manual intervention between
* the preparation and the actual request.
*
* @return a certificate for the given domains.
*/
public X509Certificate requestCertificate() throws IOException, AcmeException {
if (order == null)
throw new IllegalStateException("prepareRequest() method has not been called before the request of certificate.");
// Perform all required domain authorizations
for (Challenge challenge : challenges) {
execDomainChallenge(challenge);
}
// Generate one "Certificate Signing Request" for all the domains, and sign it with the domain key pair.
CSRBuilder csrBuilder = new CSRBuilder(); CSRBuilder csrBuilder = new CSRBuilder();
csrBuilder.addDomains(domains); csrBuilder.addDomains(domains);
csrBuilder.sign(domainKeyPair); csrBuilder.sign(dataStore.loadDomainKeyPair());
order.execute(csrBuilder.getEncoded()); // Order the certificate order.execute(csrBuilder.getEncoded()); // Order the certificate
@ -137,6 +172,10 @@ public class AcmeClient {
logger.info("The certificate for domains '" + StringUtil.join(",", domains) + "' has been successfully generated."); logger.info("The certificate for domains '" + StringUtil.join(",", domains) + "' has been successfully generated.");
// Cleanup
order = null;
challenges.clear();
return certificate.getCertificate(); return certificate.getCertificate();
} }
@ -170,19 +209,17 @@ public class AcmeClient {
return account; return account;
} }
/** /**
* Authorize a domain. It will be associated with your account, so you will be able to * Authorize a domain. It will be associated with your account, so you will be able to
* retrieve a signed certificate for the domain later. * retrieve a signed certificate for the domain later.
* *
* @param authorization {@link Authorization} to perform * @param challenge {@link Challenge} to be performed
*/ */
private void execDomainChallenge(Authorization authorization) throws AcmeException { private void execDomainChallenge(Challenge challenge) throws AcmeException {
logger.info("Authorization for domain: " + authorization.getIdentifier().getDomain()); logger.info("Executing challenge: " + challenge);
Challenge challenge = null;
try { try {
challenge = challengeFactory.createChallenge(authorization);
// If the challenge is already verified, there's no need to execute it again. // If the challenge is already verified, there's no need to execute it again.
if (challenge.getStatus() == Status.VALID) if (challenge.getStatus() == Status.VALID)
return; return;
@ -197,9 +234,7 @@ public class AcmeClient {
if (challenge.getStatus() == Status.VALID) { if (challenge.getStatus() == Status.VALID) {
break; break;
} else if (challenge.getStatus() == Status.INVALID) { } else if (challenge.getStatus() == Status.INVALID) {
//throw new AcmeException("Certificate challenge failed: " + challenge.getError()); throw new AcmeException("Certificate challenge failed: " + challenge.getError());
logger.severe("Certificate challenge failed: " + challenge.getError());
challenge.trigger();
} }
// Wait for a few seconds // Wait for a few seconds
@ -213,7 +248,7 @@ public class AcmeClient {
// All reattempts are used up and there is still no valid authorization? // All reattempts are used up and there is still no valid authorization?
if (challenge.getStatus() != Status.VALID) if (challenge.getStatus() != Status.VALID)
throw new AcmeException("Failed to pass the challenge for domain " + authorization.getIdentifier().getDomain()); throw new AcmeException("Failed to pass the challenge: " + challenge.getError());
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
logger.log(Level.SEVERE, "Interrupted", ex); logger.log(Level.SEVERE, "Interrupted", ex);
} finally { } finally {

View file

@ -19,9 +19,6 @@ public class AcmeManualDnsChallengeFactory implements AcmeChallengeFactory {
@Override @Override
public Challenge createChallenge(Authorization authorization) throws AcmeException { public Challenge createChallenge(Authorization authorization) throws AcmeException {
if (challengeCache.containsKey(authorization.getIdentifier().getDomain()))
return challengeCache.get(authorization.getIdentifier().getDomain());
Dns01Challenge challenge = authorization.findChallenge(Dns01Challenge.class); Dns01Challenge challenge = authorization.findChallenge(Dns01Challenge.class);
if (challenge == null) { if (challenge == null) {
throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge."); throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge.");
@ -31,11 +28,33 @@ public class AcmeManualDnsChallengeFactory implements AcmeChallengeFactory {
logger.warning( logger.warning(
"---------------------------- ATTENTION ----------------------------\n" + "---------------------------- ATTENTION ----------------------------\n" +
"For certificate challenge to pass please create a DNS TXT record:\n" + "Please deploy a DNS TXT record under the name\n" +
"_acme-challenge." + authorization.getIdentifier().getDomain() + ". IN TXT " + challenge.getDigest() + "\n" + getChallengeDnsName(authorization.getIdentifier().getDomain()) + " with the following value:\n\n" +
challenge.getDigest() + "\n\n" +
"Continue the process once this is deployed." +
"--------------------------------------------------------------------"); "--------------------------------------------------------------------");
challengeCache.put(authorization.getIdentifier().getDomain(), challenge); challengeCache.put(authorization.getIdentifier().getDomain(), challenge);
return challenge; return challenge;
} }
/**
* Returns the name of the record where the challenge value has to be assigned to.
*
* @param domain the specific domain
* @return a String DNS record name
*/
public String getChallengeDnsName(String domain) {
return "_acme-challenge." + domain;
}
/**
* Gives the value of the required DNS record.
*
* @param domain the specific domain
* @return a special String value that needs to be added to the domains DNS record
*/
public String getChallengeDnsValue(String domain) {
return challengeCache.get(domain).getDigest();
}
} }