From 8cf1097f63155fb2ca4cf68b47b18746f9791388 Mon Sep 17 00:00:00 2001 From: Nguyen Dang Thanh Date: Tue, 16 Jan 2024 23:19:01 +0700 Subject: [PATCH] Fix code base on review comments - Rollback ClientTest.java as default one - Using sslcom-dv-ecc for SslComAcmeProvider instead of sslcom-dv-rsa - Adding separated client test class for Ssl with EAB supported --- .../provider/sslcom/SslComAcmeProvider.java | 4 +- .../shredzone/acme4j/example/ClientTest.java | 37 +- .../acme4j/example/SSLClientWithEabTest.java | 456 ++++++++++++++++++ ssl-truststore | Bin 6700 -> 0 bytes 4 files changed, 466 insertions(+), 31 deletions(-) create mode 100644 acme4j-example/src/main/java/org/shredzone/acme4j/example/SSLClientWithEabTest.java delete mode 100644 ssl-truststore diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProvider.java index c490f063..e553815a 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/sslcom/SslComAcmeProvider.java @@ -33,8 +33,8 @@ import org.shredzone.acme4j.provider.AcmeProvider; */ public class SslComAcmeProvider extends AbstractAcmeProvider { - private static final String V02_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-rsa"; - private static final String STAGING_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-rsa"; + private static final String V02_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-ecc"; + private static final String STAGING_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-ecc"; @Override public boolean accepts(URI serverUri) { diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java index aac326bf..d16564b7 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java @@ -78,25 +78,18 @@ public class ClientTest { * * @param domains * Domains to get a common certificate for - * @param eabKid - * Value of --eab-kid - * @param eabHmacKey - * Value of --eab-hmac-key - * @param emailAddress - * Email address of account that owns the key information */ - public void fetchCertificate(Collection domains, String eabKid, String eabHmacKey, String emailAddress) throws IOException, AcmeException { + public void fetchCertificate(Collection domains) throws IOException, AcmeException { // Load the user key file. If there is no key file, create a new one. KeyPair userKeyPair = loadOrCreateUserKeyPair(); // Create a session for Let's Encrypt. // Use "acme://letsencrypt.org" for production server - Session session = new Session("acme://ssl.com/staging"); - //Session session = new Session("acme://letsencrypt.org/staging"); + Session session = new Session("acme://letsencrypt.org/staging"); // Get the Account. // If there is no account yet, create a new one. - Account acct = findOrRegisterAccount(session, userKeyPair, eabKid, eabHmacKey, emailAddress); + Account acct = findOrRegisterAccount(session, userKeyPair); // Load or create a key pair for the domains. This should not be the userKeyPair! KeyPair domainKeyPair = loadOrCreateDomainKeyPair(); @@ -209,27 +202,16 @@ public class ClientTest { * * @param session * {@link Session} to bind with - * @param eabKid - * Value of --eab-kid - * @param eabHmacKey - * Value of --eab-hmac-key - * @param emailAddress - * Email address of account that owns the key information * @return {@link Account} */ - private Account findOrRegisterAccount(Session session, KeyPair accountKey, String eabKid, String eabHmacKey, String emailAddress) throws AcmeException { + 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(); - if (eabKid != null && eabHmacKey != null && emailAddress != null) { - accountBuilder = accountBuilder.withKeyIdentifier(eabKid, eabHmacKey) - .addEmail(emailAddress); - } - Account account = accountBuilder + Account account = new AccountBuilder() .agreeToTermsOfService() .useKeyPair(accountKey) .create(session); @@ -444,7 +426,7 @@ public class ClientTest { */ public static void main(String... args) { if (args.length == 0) { - System.err.println("Usage: ClientTest (optional) (optional) (optional)"); + System.err.println("Usage: ClientTest ..."); System.exit(1); } @@ -452,13 +434,10 @@ public class ClientTest { Security.addProvider(new BouncyCastleProvider()); - Collection domains = Arrays.asList(args[0].split(",")); - String eabKid = args.length > 1 ? args[1] : null; - String eabHmacKey = args.length > 2 ? args[2] : null; - String emailAddress = args.length > 3 ? args[3] : null; + Collection domains = Arrays.asList(args); try { ClientTest ct = new ClientTest(); - ct.fetchCertificate(domains, eabKid, eabHmacKey, emailAddress); + ct.fetchCertificate(domains); } catch (Exception ex) { LOG.error("Failed to get a certificate for domains " + domains, ex); } diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/example/SSLClientWithEabTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/example/SSLClientWithEabTest.java new file mode 100644 index 00000000..11dd5ed5 --- /dev/null +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/example/SSLClientWithEabTest.java @@ -0,0 +1,456 @@ +/* + * 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 org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.shredzone.acme4j.*; +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; + +import javax.swing.*; +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.util.Arrays; +import java.util.Collection; +import java.util.Optional; + +/** + * A simple client test tool. + *

+ * Pass the names of the domains as parameters. + */ +public class SSLClientWithEabTest { + // 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; + + // RSA key size of generated key pairs + private static final int KEY_SIZE = 2048; + + private static final Logger LOG = LoggerFactory.getLogger(SSLClientWithEabTest.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 + * @param eabKid + * Value of --eab-kid + * @param eabHmacKey + * Value of --eab-hmac-key + * @param emailAddress + * Email address of account that owns the key information + */ + public void fetchCertificate(Collection domains, String eabKid, String eabHmacKey, String emailAddress) throws IOException, AcmeException { + // Load the user key file. If there is no key file, create a new one. + KeyPair userKeyPair = loadOrCreateUserKeyPair(); + + // Create a session for Let's Encrypt. + // Use "acme://letsencrypt.org" for production server + Session session = new Session("acme://ssl.com/staging"); + //Session session = new Session("acme://letsencrypt.org/staging"); + + // Get the Account. + // If there is no account yet, create a new one. + Account acct = findOrRegisterAccount(session, userKeyPair, eabKid, eabHmacKey, emailAddress); + + // 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); + } + + // Order the certificate + order.execute(domainKeyPair); + + // Wait for the order to complete + try { + int attempts = 10; + while (order.getStatus() != Status.VALID && attempts-- > 0) { + // Did the order fail? + if (order.getStatus() == Status.INVALID) { + LOG.error("Order has failed, reason: {}", order.getError() + .map(Problem::toString) + .orElse("unknown") + ); + throw new AcmeException("Order failed... Giving up."); + } + + // Wait for a few seconds + Thread.sleep(3000L); + + // Then update the status + order.update(); + } + } catch (InterruptedException ex) { + LOG.error("interrupted", ex); + Thread.currentThread().interrupt(); + } + + // 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 = KeyPairUtils.createKeyPair(KEY_SIZE); + 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 = KeyPairUtils.createKeyPair(); + 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 + * @param eabKid + * Value of --eab-kid + * @param eabHmacKey + * Value of --eab-hmac-key + * @param emailAddress + * Email address of account that owns the key information + * @return {@link Account} + */ + private Account findOrRegisterAccount(Session session, KeyPair accountKey, String eabKid, String eabHmacKey, String emailAddress) 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(); + if (eabKid != null && eabHmacKey != null && emailAddress != null) { + accountBuilder = accountBuilder.withKeyIdentifier(eabKid, eabHmacKey) + .addEmail(emailAddress); + } + Account account = accountBuilder + .agreeToTermsOfService() + .useKeyPair(accountKey) + .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 { + 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 = null; + switch (CHALLENGE_TYPE) { + case HTTP: + challenge = httpChallenge(auth); + break; + + case DNS: + challenge = dnsChallenge(auth); + 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) { + LOG.error("Challenge has failed, reason: {}", challenge.getError() + .map(Problem::toString) + .orElse("unknown")); + 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 " + + auth.getIdentifier().getDomain() + ", ... 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 {}", + Dns01Challenge.toRRName(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(Dns01Challenge.toRRName(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 (optional) (optional) (optional)"); + System.exit(1); + } + + LOG.info("Starting up..."); + + Security.addProvider(new BouncyCastleProvider()); + + Collection domains = Arrays.asList(args[0].split(",")); + String eabKid = args.length > 1 ? args[1] : null; + String eabHmacKey = args.length > 2 ? args[2] : null; + String emailAddress = args.length > 3 ? args[3] : null; + try { + SSLClientWithEabTest ct = new SSLClientWithEabTest(); + ct.fetchCertificate(domains, eabKid, eabHmacKey, emailAddress); + } catch (Exception ex) { + LOG.error("Failed to get a certificate for domains " + domains, ex); + } + } + +} diff --git a/ssl-truststore b/ssl-truststore deleted file mode 100644 index f687460f40e65b2797b09ceab3f4005c9d44c3af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6700 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