From e19c11d4079ae4c514aded4990a6d2fc4349795f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sat, 24 Dec 2016 14:05:03 +0100 Subject: [PATCH] Clean up example and make it more comprehensible --- .../java/org/shredzone/acme4j/ClientTest.java | 450 +++++++++++------- 1 file changed, 275 insertions(+), 175 deletions(-) diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java index 229f49be..0b46dfd3 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java @@ -33,7 +33,6 @@ import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.exception.AcmeConflictException; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.exception.AcmeUnauthorizedException; import org.shredzone.acme4j.util.CSRBuilder; import org.shredzone.acme4j.util.CertificateUtils; import org.shredzone.acme4j.util.KeyPairUtils; @@ -46,17 +45,28 @@ import org.slf4j.LoggerFactory; * Pass the names of the domains as parameters. */ public class ClientTest { + // 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"); - private static final File DOMAIN_CERT_FILE = new File("domain.crt"); - private static final File CERT_CHAIN_FILE = new File("chain.crt"); - private static final File DOMAIN_CHAIN_FILE = new File("domain-chain.crt"); + + // File name of the CSR private static final File DOMAIN_CSR_FILE = new File("domain.csr"); + // 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; + + // RSA key size of generated key pairs private static final int KEY_SIZE = 2048; private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class); + private enum ChallengeType { HTTP, DNS, TLSSNI } + /** * Generates a certificate for the given domains. Also takes care for the registration * process. @@ -65,160 +75,230 @@ public class ClientTest { * Domains to get a common certificate for */ public void fetchCertificate(Collection domains) throws IOException, AcmeException { - // Load or create a key pair for the user's account - boolean createdNewKeyPair = false; + // Load the user key file. If there is no key file, create a new one. + KeyPair userKeyPair = loadOrCreateUserKeyPair(); - KeyPair userKeyPair; - if (USER_KEY_FILE.exists()) { - try (FileReader fr = new FileReader(USER_KEY_FILE)) { - userKeyPair = KeyPairUtils.readKeyPair(fr); - } - } else { - userKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); - try (FileWriter fw = new FileWriter(USER_KEY_FILE)) { - KeyPairUtils.writeKeyPair(userKeyPair, fw); - } - createdNewKeyPair = true; - } - - // Create a session for Let's Encrypt + // Create a session for Let's Encrypt. // Use "acme://letsencrypt.org" for production server Session session = new Session("acme://letsencrypt.org/staging", userKeyPair); - // Register a new user - Registration reg = null; - try { - reg = new RegistrationBuilder().create(session); - LOG.info("Registered a new user, URI: " + reg.getLocation()); - } catch (AcmeConflictException ex) { - LOG.trace("acme4j exception caught", ex); - reg = Registration.bind(session, ex.getLocation()); - LOG.info("Account does already exist, URI: " + reg.getLocation()); - } - - URI agreement = reg.getAgreement(); - LOG.info("Terms of Service: " + agreement); - - if (createdNewKeyPair) { - boolean accepted = acceptAgreement(reg, agreement); - if (!accepted) { - return; - } - } + // Get the Registration to the account. + // If there is no account yet, create a new one. + Registration reg = findOrRegisterAccount(session); + // Separately authorize every requested domain. for (String domain : domains) { - // Create a new authorization - Authorization auth = null; - try { - auth = reg.authorizeDomain(domain); - } catch (AcmeUnauthorizedException ex) { - // Maybe there are new T&C to accept? - LOG.trace("acme4j exception caught", ex); - boolean accepted = acceptAgreement(reg, agreement); - if (!accepted) { - return; - } - // Then try again... - auth = reg.authorizeDomain(domain); - } - LOG.info("New authorization for domain " + domain); - - // Uncomment a challenge... - Challenge challenge = httpChallenge(auth, domain); -// Challenge challenge = dnsChallenge(auth, domain); -// Challenge challenge = tlsSniChallenge(auth, domain); - - if (challenge == null) { - return; - } - - // Trigger the challenge - challenge.trigger(); - - // Poll for the challenge to complete - try { - int attempts = 10; - while (challenge.getStatus() != Status.VALID && attempts-- > 0) { - if (challenge.getStatus() == Status.INVALID) { - LOG.error("Challenge failed... Giving up."); - return; - } - Thread.sleep(3000L); - challenge.update(); - } - } catch (InterruptedException ex) { - LOG.error("interrupted", ex); - Thread.currentThread().interrupt(); - } - - if (challenge.getStatus() != Status.VALID) { - LOG.error("Failed to pass the challenge... Giving up."); - return; - } + authorize(reg, domain); } - // Load or create a key pair for the domain - KeyPair domainKeyPair; - if (DOMAIN_KEY_FILE.exists()) { - try (FileReader fr = new FileReader(DOMAIN_KEY_FILE)) { - domainKeyPair = KeyPairUtils.readKeyPair(fr); - } - } else { - domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); - try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) { - KeyPairUtils.writeKeyPair(domainKeyPair, fw); - } - } + // Load or create a key pair for the domains. This should not be the userKeyPair! + KeyPair domainKeyPair = loadOrCreateDomainKeyPair(); - // Generate a CSR for the domain + // Generate a CSR for all of the domains, and sign it with the domain key pair. CSRBuilder csrb = new CSRBuilder(); csrb.addDomains(domains); csrb.sign(domainKeyPair); + // Write the CSR to a file, for later use. try (Writer out = new FileWriter(DOMAIN_CSR_FILE)) { csrb.write(out); } - // Request a signed certificate + // Now request a signed certificate. Certificate certificate = reg.requestCertificate(csrb.getEncoded()); + LOG.info("Success! The certificate for domains " + domains + " has been generated!"); LOG.info("Certificate URI: " + certificate.getLocation()); - // Download the certificate + // Download the leaf certificate and certificate chain. X509Certificate cert = certificate.download(); X509Certificate[] chain = certificate.downloadChain(); - // Write certificate only (e.g. for Apache's SSLCertificateFile) - try (FileWriter fw = new FileWriter(DOMAIN_CERT_FILE)) { - CertificateUtils.writeX509Certificate(cert, fw); - } - - // Write chain only (e.g. for Apache's SSLCertificateChainFile) - try (FileWriter fw = new FileWriter(CERT_CHAIN_FILE)) { - CertificateUtils.writeX509CertificateChain(fw, null, chain); - } - - // Write combined certificate and chain (e.g. for nginx) + // Write a combined file containing the certificate and chain. try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) { CertificateUtils.writeX509CertificateChain(fw, cert, chain); } - // Revoke the certificate (uncomment if needed...) - // certificate.revoke(); - - // Deactivate the registration (uncomment if needed...) - // reg.deactivate(); + // That's all! Configure your web server to use the DOMAIN_KEY_FILE and + // DOMAIN_CHAIN_FILE for the requested domans. } /** - * Prepares HTTP challenge. + * Loads a user key pair from {@value #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 = KeyPairUtils.createKeyPair(KEY_SIZE); + try (FileWriter fw = new FileWriter(USER_KEY_FILE)) { + KeyPairUtils.writeKeyPair(userKeyPair, fw); + } + return userKeyPair; + } + } + + /** + * Loads a domain key pair from {@value #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 = KeyPairUtils.createKeyPair(KEY_SIZE); + try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) { + KeyPairUtils.writeKeyPair(domainKeyPair, fw); + } + return domainKeyPair; + } + } + + /** + * Finds your {@link Registration} 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 registration will be + * created. + *

+ * This is a simple way of finding your {@link Registration}. A better way is to get + * the URI of your new registration with {@link Registration#getLocation()} and store + * it somewhere. If you need to get access to your account later, reconnect to it via + * {@link Registration#bind(Session, URI)} by using the stored location. + * + * @param session + * {@link Session} to bind with + * @return {@link Registration} connected to your account + */ + private Registration findOrRegisterAccount(Session session) throws AcmeException { + Registration reg; + + try { + // Try to create a new Registration. + reg = new RegistrationBuilder().create(session); + LOG.info("Registered a new user, URI: " + reg.getLocation()); + + // This is a new account. Let the user accept the Terms of Service. + // We won't be able to authorize domains until the ToS is accepted. + URI agreement = reg.getAgreement(); + LOG.info("Terms of Service: " + agreement); + acceptAgreement(reg, agreement); + + } catch (AcmeConflictException ex) { + // The Key Pair is already registered. getLocation() contains the + // URL of the existing registration's location. Bind it to the session. + reg = Registration.bind(session, ex.getLocation()); + LOG.info("Account does already exist, URI: " + reg.getLocation(), ex); + } + + return reg; + } + + /** + * Authorize a domain. It will be associated with your account, so you will be able to + * retrieve a signed certificate for the domain later. + *

+ * You need separate authorizations for subdomains (e.g. "www" subdomain). Wildcard + * certificates are not currently supported. + * + * @param reg + * {@link Registration} of your account + * @param domain + * Name of the domain to authorize + */ + private void authorize(Registration reg, String domain) throws AcmeException { + // Authorize the domain. + Authorization auth = reg.authorizeDomain(domain); + LOG.info("Authorization for domain " + domain); + + // Find the desired challenge and prepare it. + Challenge challenge = null; + switch (CHALLENGE_TYPE) { + case HTTP: + challenge = httpChallenge(auth, domain); + break; + + case DNS: + challenge = dnsChallenge(auth, domain); + break; + + case TLSSNI: + challenge = tlsSniChallenge(auth, domain); + break; + } + + 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. + try { + int attempts = 10; + while (challenge.getStatus() != Status.VALID && attempts-- > 0) { + // Did the authorization fail? + if (challenge.getStatus() == Status.INVALID) { + throw new AcmeException("Challenge failed... Giving up."); + } + + // Wait for a few seconds + Thread.sleep(3000L); + + // Then update the status + challenge.update(); + } + } catch (InterruptedException ex) { + LOG.error("interrupted", ex); + Thread.currentThread().interrupt(); + } + + // All reattempts are used up and there is still no valid authorization? + if (challenge.getStatus() != Status.VALID) { + throw new AcmeException("Failed to pass the challenge for domain " + domain + ", ... Giving up."); + } + } + + /** + * 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 + * @param domain + * Domain name to be authorized + * @return {@link Challenge} to verify */ public Challenge httpChallenge(Authorization auth, String domain) throws AcmeException { // Find a single http-01 challenge Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE); if (challenge == null) { - LOG.error("Found no " + Http01Challenge.TYPE + " challenge, don't know what to do..."); - return null; + throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge, don't know what to do..."); } // Output the challenge, wait for acknowledge... @@ -234,27 +314,30 @@ public class ClientTest { message.append("http://").append(domain).append("/.well-known/acme-challenge/").append(challenge.getToken()).append("\n\n"); message.append("Content:\n\n"); message.append(challenge.getAuthorization()); - int option = JOptionPane.showConfirmDialog(null, - message.toString(), - "Prepare Challenge", - JOptionPane.OK_CANCEL_OPTION); - if (option == JOptionPane.CANCEL_OPTION) { - LOG.error("User cancelled challenge"); - return null; - } + acceptChallenge(message.toString()); return challenge; } /** - * Prepares DNS 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 + * @param domain + * Domain name to be authorized + * @return {@link Challenge} to verify */ public Challenge dnsChallenge(Authorization auth, String domain) throws AcmeException { // Find a single dns-01 challenge Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.TYPE); if (challenge == null) { - LOG.error("Found no " + Dns01Challenge.TYPE + " challenge, don't know what to do..."); - return null; + throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge, don't know what to do..."); } // Output the challenge, wait for acknowledge... @@ -265,50 +348,52 @@ public class ClientTest { StringBuilder message = new StringBuilder(); message.append("Please create a TXT record:\n\n"); message.append("_acme-challenge." + domain + ". IN TXT " + challenge.getDigest()); - int option = JOptionPane.showConfirmDialog(null, - message.toString(), - "Prepare Challenge", - JOptionPane.OK_CANCEL_OPTION); - if (option == JOptionPane.CANCEL_OPTION) { - LOG.error("User cancelled challenge"); - return null; - } + acceptChallenge(message.toString()); return challenge; } /** - * Prepares TLS-SNI challenge. + * Prepares a TLS-SNI challenge. + *

+ * The verification of this challenge expects that the web server returns a special + * validation certificate. + *

+ * This example outputs instructions that need to be executed manually. In a + * production environment, you would rather configure your web server automatically. + * + * @param auth + * {@link Authorization} to find the challenge in + * @param domain + * Domain name to be authorized + * @return {@link Challenge} to verify */ @SuppressWarnings("deprecation") // until tls-sni-02 is supported public Challenge tlsSniChallenge(Authorization auth, String domain) throws AcmeException { // Find a single tls-sni-01 challenge org.shredzone.acme4j.challenge.TlsSni01Challenge challenge = auth.findChallenge(org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE); if (challenge == null) { - LOG.error("Found no " + org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE + " challenge, don't know what to do..."); - return null; + throw new AcmeException("Found no " + org.shredzone.acme4j.challenge.TlsSni01Challenge.TYPE + " challenge, don't know what to do..."); } // Get the Subject String subject = challenge.getSubject(); - // Create a keypair + // Create a validation key pair KeyPair domainKeyPair; try (FileWriter fw = new FileWriter("tlssni.key")) { domainKeyPair = KeyPairUtils.createKeyPair(2048); KeyPairUtils.writeKeyPair(domainKeyPair, fw); } catch (IOException ex) { - LOG.error("Could not create keypair", ex); - return null; + throw new AcmeException("Could not write keypair", ex); } - // Create a certificate + // Create a validation certificate try (FileWriter fw = new FileWriter("tlssni.crt")) { X509Certificate cert = CertificateUtils.createTlsSniCertificate(domainKeyPair, subject); CertificateUtils.writeX509Certificate(cert, fw); } catch (IOException ex) { - LOG.error("Could not create certificate", ex); - return null; + throw new AcmeException("Could not write certificate", ex); } // Output the challenge, wait for acknowledge... @@ -321,42 +406,57 @@ public class ClientTest { StringBuilder message = new StringBuilder(); message.append("Please use 'tlssni.key' and 'tlssni.crt' cert for SNI requests to:\n\n"); message.append("https://").append(subject).append("\n\n"); - int option = JOptionPane.showConfirmDialog(null, - message.toString(), - "Prepare Challenge", - JOptionPane.OK_CANCEL_OPTION); - if (option == JOptionPane.CANCEL_OPTION) { - LOG.error("User cancelled challenge"); - return null; - } + acceptChallenge(message.toString()); return challenge; } /** - * Presents the user a link to the Terms of Service, and asks for confirmation. + * Presents the instructions for preparing the challenge validation, and waits for + * dismissal. If the user cancelled the dialog, an exception is thrown. * - * @param reg - * {@link Registration} User's registration, containing the Agreement URI - * @return {@code true}: User confirmed, {@code false} user rejected + * @param message + * Instructions to be shown in the dialog */ - public boolean acceptAgreement(Registration reg, URI agreement) - throws AcmeException { + public void acceptChallenge(String message) throws AcmeException { int option = JOptionPane.showConfirmDialog(null, - "Do you accept the Terms of Service?\n\n" + agreement, - "Accept T&C", - JOptionPane.YES_NO_OPTION); - if (option == JOptionPane.NO_OPTION) { - LOG.error("User did not accept Terms of Service"); - return false; + message, + "Prepare Challenge", + JOptionPane.OK_CANCEL_OPTION); + if (option == JOptionPane.CANCEL_OPTION) { + throw new AcmeException("User cancelled the challenge"); } - - reg.modify().setAgreement(agreement).commit(); - LOG.info("Updated user's ToS"); - - return true; } + /** + * Presents the user a link to the Terms of Service, and asks for confirmation. If the + * user denies confirmation, an exception is thrown. + * + * @param reg + * {@link Registration} User's registration + * @param agreement + * {@link URI} of the Terms of Service + */ + public void acceptAgreement(Registration reg, 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"); + } + + // Motify the Registration and accept the agreement + reg.modify().setAgreement(agreement).commit(); + LOG.info("Updated user's ToS"); + } + + /** + * 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 ...");