Added factory for ACME protocol

This commit is contained in:
Ziver Koc 2021-08-19 02:12:30 +02:00
parent 4955731abc
commit 708577debf
8 changed files with 192 additions and 58 deletions

View file

@ -27,7 +27,7 @@ package zutil.math;
import java.math.BigInteger; import java.math.BigInteger;
/** /**
* Very Simple math functions * Very Simple math functions
* *
* @author Ziver * @author Ziver
*/ */

View file

@ -0,0 +1,22 @@
package zutil.net.acme;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.exception.AcmeException;
public interface AcmeChallengeFactory {
/**
* Create a new Challenge object and do any needed actions for the challenge to proceed.
*
* @param authorization
* @return a Challenge object that will be used to authorize a domain.
* @throws AcmeException in case of any issues
*/
Challenge createChallenge(Authorization authorization) throws AcmeException;
/**
* Do any needed cleanup after challenge has completed successfully or failed.
*/
default void postChallengeAction(Challenge challenge) {}
}

View file

@ -17,14 +17,13 @@ import org.shredzone.acme4j.Certificate;
import org.shredzone.acme4j.Order; import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status; import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Http01Challenge; 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;
import org.shredzone.acme4j.util.KeyPairUtils; import org.shredzone.acme4j.util.KeyPairUtils;
import zutil.StringUtil; import zutil.StringUtil;
import zutil.log.LogUtil; import zutil.log.LogUtil;
import zutil.net.http.HttpServer; import zutil.net.http.HttpServer;
import zutil.net.http.page.HttpStaticContentPage;
/** /**
* A class implementing the ACME protocol (Automatic Certificate Management Environment) used by the LetsEncrypt service. * A class implementing the ACME protocol (Automatic Certificate Management Environment) used by the LetsEncrypt service.
@ -43,31 +42,35 @@ public class AcmeClient {
private String acmeServerUrl; private String acmeServerUrl;
private AcmeDataStore dataStore; private AcmeDataStore dataStore;
private AcmeChallengeFactory challengeFactory;
public AcmeClient(AcmeDataStore dataStore, HttpServer httpServer) {
this(dataStore, new AcmeHttpChallengeFactory(httpServer), ACME_SERVER_LETSENCRYPT_PRODUCTION);
}
public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory) {
this(dataStore, challengeFactory, ACME_SERVER_LETSENCRYPT_PRODUCTION);
}
/** /**
* Create a new instance of the ACME Client * Create a new instance of the ACME Client
*/ */
public AcmeClient(AcmeDataStore dataStore, String acmeServerUrl) { public AcmeClient(AcmeDataStore dataStore, AcmeChallengeFactory challengeFactory, String acmeServerUrl) {
Security.addProvider(new BouncyCastleProvider()); Security.addProvider(new BouncyCastleProvider());
this.dataStore = dataStore; this.dataStore = dataStore;
this.challengeFactory = challengeFactory;
this.acmeServerUrl = acmeServerUrl; this.acmeServerUrl = acmeServerUrl;
} }
public AcmeClient(AcmeDataStore dataStore) {
this(dataStore, ACME_SERVER_LETSENCRYPT_PRODUCTION);
}
/** /**
* Generates a certificate for the given domains. Also takes care for the registration * Generates a certificate for the given domains. Also takes care for the registration
* process. * process.
* *
* @param httpServer the web server where the challenge and response will be performed on and where the certificate will be applied to.
* @param domains the domains to get a certificates for * @param domains the domains to get a certificates for
* @return a certificate for the given domains. * @return a certificate for the given domains.
*/ */
public X509Certificate fetchCertificate(HttpServer httpServer, String... domains) throws IOException, AcmeException { public X509Certificate fetchCertificate(String... domains) throws IOException, AcmeException {
// ------------------------------------------------ // ------------------------------------------------
// Read in keys // Read in keys
// ------------------------------------------------ // ------------------------------------------------
@ -94,7 +97,10 @@ public class AcmeClient {
// Perform all required authorizations // Perform all required authorizations
for (Authorization auth : order.getAuthorizations()) { for (Authorization auth : order.getAuthorizations()) {
execHttpChallenge(auth, httpServer); if (auth.getStatus() == Status.VALID)
continue; // The authorization is already valid. No need to process a challenge.
execDomainChallenge(auth);
} }
// Generate a "Certificate Signing Request" for all of the domains, and sign it with the domain key pair. // Generate a "Certificate Signing Request" for all of the domains, and sign it with the domain key pair.
@ -106,7 +112,7 @@ public class AcmeClient {
// Wait for the order to complete // Wait for the order to complete
try { try {
for (int attempts = 0; attempts < 10; attempts--) { for (int attempts = 0; attempts < 10; attempts++) {
// Did the order pass or fail? // Did the order pass or fail?
if (order.getStatus() == Status.VALID) { if (order.getStatus() == Status.VALID) {
break; break;
@ -115,7 +121,9 @@ public class AcmeClient {
} }
// Wait for a few seconds // Wait for a few seconds
Thread.sleep(100L + 500L * attempts); long sleep = 100L + 1000L * attempts;
logger.fine("Challenge not yet completed, sleeping for: " + StringUtil.formatTimeToString(sleep));
Thread.sleep(sleep);
// Then update the status // Then update the status
order.update(); order.update();
@ -166,49 +174,38 @@ public class AcmeClient {
* 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 auth {@link Authorization} to perform * @param authorization {@link Authorization} to perform
*/ */
private void execHttpChallenge(Authorization auth, HttpServer httpServer) throws AcmeException { private void execDomainChallenge(Authorization authorization) throws AcmeException {
logger.info("Authorization for domain: " + auth.getIdentifier().getDomain()); logger.info("Authorization for domain: " + authorization.getIdentifier().getDomain());
Challenge challenge = null;
// The authorization is already valid. No need to process a challenge.
if (auth.getStatus() == Status.VALID) {
return;
}
// Find the desired challenge and prepare it.
Http01Challenge challenge = auth.findChallenge(Http01Challenge.class);
if (challenge == null) {
throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge.");
}
// If the challenge is already verified, there's no need to execute it again.
if (challenge.getStatus() == Status.VALID)
return;
String url = "http://" + auth.getIdentifier().getDomain();
String path = "/.well-known/acme-challenge/" + challenge.getToken();
String content = challenge.getAuthorization();
// Output the challenge, wait for acknowledge...
logger.fine("Adding challenge HttpPage at: " + url + path);
httpServer.setPage(path, new HttpStaticContentPage(content));
// Now trigger the challenge.
challenge.trigger();
// Poll for the challenge to complete.
try { try {
for (int attempts = 0; attempts < 10; attempts--) { challenge = challengeFactory.createChallenge(authorization);
// 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.
for (int attempts = 0; attempts < 30; attempts++) {
// Did the authorization fail? // Did the authorization fail?
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
Thread.sleep(100L + 500L * attempts); long sleep = 100L + 5000L * attempts;
logger.fine("Challenge not yet completed, sleeping for: " + StringUtil.formatTimeToString(sleep));
Thread.sleep(sleep);
// Then update the status // Then update the status
challenge.update(); challenge.update();
@ -216,12 +213,14 @@ 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 " + auth.getIdentifier().getDomain()); throw new AcmeException("Failed to pass the challenge for domain " + authorization.getIdentifier().getDomain());
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
logger.log(Level.SEVERE, "Interrupted", ex); logger.log(Level.SEVERE, "Interrupted", ex);
} finally { } finally {
// Cleanup // Cleanup
httpServer.removePage(path); challengeFactory.postChallengeAction(challenge);
} }
logger.fine("Domain challenge executed successfully.");
} }
} }

View file

@ -9,8 +9,6 @@ public class AcmeFileDataStore implements AcmeDataStore {
private final File userKeyFile; private final File userKeyFile;
private final File domainKeyFile; private final File domainKeyFile;
private final File domainCsrFile;
private final File domainChainFile;
/** /**
@ -21,8 +19,6 @@ public class AcmeFileDataStore implements AcmeDataStore {
public AcmeFileDataStore(File folder) { public AcmeFileDataStore(File folder) {
this.userKeyFile = new File(folder, "user.key"); this.userKeyFile = new File(folder, "user.key");
this.domainKeyFile = new File(folder, "domain.key"); this.domainKeyFile = new File(folder, "domain.key");
this.domainCsrFile = new File(folder, "domain.csr");
this.domainChainFile = new File(folder, "domain-chain.crt");
} }

View file

@ -0,0 +1,55 @@
package zutil.net.acme;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import zutil.log.LogUtil;
import zutil.net.http.HttpServer;
import zutil.net.http.HttpURL;
import zutil.net.http.page.HttpStaticContentPage;
import java.util.logging.Logger;
/**
* Class implementing HTTP based challenge logic of the ACME protocol through the Zutil HttpServer.
*/
public class AcmeHttpChallengeFactory implements AcmeChallengeFactory {
private static final Logger logger = LogUtil.getLogger();
private HttpServer httpServer;
private HttpURL url;
public AcmeHttpChallengeFactory(HttpServer httpServer) {
this.httpServer = httpServer;
}
@Override
public Challenge createChallenge(Authorization authorization) throws AcmeException {
Http01Challenge challenge = authorization.findChallenge(Http01Challenge.class);
if (challenge == null) {
throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge.");
}
url = new HttpURL();
url.setProtocol("http");
url.setHost(authorization.getIdentifier().getDomain());
url.setPort(httpServer.getPort());
url.setPath("/.well-known/acme-challenge/" + challenge.getToken());
// Output the challenge, wait for acknowledge...
logger.fine("Adding challenge HttpPage at: " + url);
httpServer.setPage(url.getPath(), new HttpStaticContentPage(challenge.getAuthorization()));
return challenge;
}
@Override
public void postChallengeAction(Challenge challenge) {
if (url != null) {
httpServer.removePage(url.getPath());
}
}
}

View file

@ -0,0 +1,41 @@
package zutil.net.acme;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import zutil.log.LogUtil;
import java.util.HashMap;
import java.util.logging.Logger;
/**
* Class implementing HTTP based challenge logic of the ACME protocol through the Zutil HttpServer.
*/
public class AcmeManualDnsChallengeFactory implements AcmeChallengeFactory {
private static final Logger logger = LogUtil.getLogger();
private HashMap<String,Dns01Challenge> challengeCache = new HashMap<>();
@Override
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);
if (challenge == null) {
throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge.");
}
// Notify user of the required manual intervention
logger.warning(
"---------------------------- ATTENTION ----------------------------\n" +
"For certificate challenge to pass please create a DNS TXT record:\n" +
"_acme-challenge." + authorization.getIdentifier().getDomain() + ". IN TXT " + challenge.getDigest() + "\n" +
"--------------------------------------------------------------------");
challengeCache.put(authorization.getIdentifier().getDomain(), challenge);
return challenge;
}
}

View file

@ -34,6 +34,8 @@ import java.io.BufferedInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -78,11 +80,23 @@ public class HttpServer extends ThreadedTCPNetworkServer{
logger.info("HTTP Server ready and listening to port: " + port); logger.info("HTTP Server ready and listening to port: " + port);
} }
/** /**
* Creates a new instance of the sever * Creates a new instance of the sever which accepts SSL connections
* *
* @param port The port that the server should listen to * @param port The port that the server should listen to
* @param keyStore If this is not null then the server will use SSL connection with this keyStore file path * @param certificate The certificate that should be used for the servers SSL connections
* @param keyStorePass If this is not null then the server will use a SSL connection with the given certificate */
public HttpServer(int port, Certificate certificate) throws IOException {
super(port, certificate);
initGarbageCollector();
logger.info("HTTPS Server ready and listening to port: " + port);
}
/**
* Creates a new instance of the sever which accepts SSL connections
*
* @param port The port that the server should listen to
* @param keyStore The keyStore containing the certificate to use for the servers SSL connections
* @param keyStorePass The password to unlock the key store.
*/ */
public HttpServer(int port, File keyStore, char[] keyStorePass) throws IOException { public HttpServer(int port, File keyStore, char[] keyStorePass) throws IOException {
super(port, keyStore, keyStorePass); super(port, keyStore, keyStorePass);

View file

@ -36,6 +36,7 @@ import java.io.IOException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.security.*; import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -81,7 +82,7 @@ public abstract class ThreadedTCPNetworkServer extends Thread {
* @param port the port that the server should listen to. * @param port the port that the server should listen to.
* @param certificate the certificate for the servers domain. * @param certificate the certificate for the servers domain.
*/ */
public ThreadedTCPNetworkServer(int port, X509Certificate certificate) throws IOException { public ThreadedTCPNetworkServer(int port, Certificate certificate) throws IOException {
this.port = port; this.port = port;
this.serverSocket = createSSLSocket(port, certificate); this.serverSocket = createSSLSocket(port, certificate);
} }
@ -112,7 +113,7 @@ public abstract class ThreadedTCPNetworkServer extends Thread {
* @param certificate the certificate for the servers domain. * @param certificate the certificate for the servers domain.
* @return a SSLServerSocket object * @return a SSLServerSocket object
*/ */
private static ServerSocket createSSLSocket(int port, X509Certificate certificate) throws IOException{ private static ServerSocket createSSLSocket(int port, Certificate certificate) throws IOException{
try { try {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null); // Create empty keystore keyStore.load(null); // Create empty keystore
@ -144,10 +145,16 @@ public abstract class ThreadedTCPNetworkServer extends Thread {
return socketFactory.createServerSocket(port); return socketFactory.createServerSocket(port);
} }
/**
* @return the port that this TCP server is listening to.
*/
public int getPort() {
return port;
}
public void run() { public void run() {
try { try {
logger.info("Listening for TCP Connections on port: " + port); logger.info("Accepting TCP Connections on port: " + port);
while (true) { while (true) {
Socket connectionSocket = serverSocket.accept(); Socket connectionSocket = serverSocket.accept();