001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2015 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j;
015
016import java.io.File;
017import java.io.FileReader;
018import java.io.FileWriter;
019import java.io.IOException;
020import java.io.Writer;
021import java.net.URI;
022import java.security.KeyPair;
023import java.security.Security;
024import java.security.cert.X509Certificate;
025import java.util.Arrays;
026import java.util.Collection;
027
028import javax.swing.JOptionPane;
029
030import org.bouncycastle.jce.provider.BouncyCastleProvider;
031import org.shredzone.acme4j.challenge.Challenge;
032import org.shredzone.acme4j.challenge.Dns01Challenge;
033import org.shredzone.acme4j.challenge.Http01Challenge;
034import org.shredzone.acme4j.exception.AcmeConflictException;
035import org.shredzone.acme4j.exception.AcmeException;
036import org.shredzone.acme4j.util.CSRBuilder;
037import org.shredzone.acme4j.util.CertificateUtils;
038import org.shredzone.acme4j.util.KeyPairUtils;
039import org.slf4j.Logger;
040import org.slf4j.LoggerFactory;
041
042/**
043 * A simple client test tool.
044 * <p>
045 * Pass the names of the domains as parameters.
046 */
047public class ClientTest {
048    // File name of the User Key Pair
049    private static final File USER_KEY_FILE = new File("user.key");
050
051    // File name of the Domain Key Pair
052    private static final File DOMAIN_KEY_FILE = new File("domain.key");
053
054    // File name of the CSR
055    private static final File DOMAIN_CSR_FILE = new File("domain.csr");
056
057    // File name of the signed certificate
058    private static final File DOMAIN_CHAIN_FILE = new File("domain-chain.crt");
059
060    //Challenge type to be used
061    private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
062
063    // RSA key size of generated key pairs
064    private static final int KEY_SIZE = 2048;
065
066    private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
067
068    private enum ChallengeType { HTTP, DNS }
069
070    /**
071     * Generates a certificate for the given domains. Also takes care for the registration
072     * process.
073     *
074     * @param domains
075     *            Domains to get a common certificate for
076     */
077    public void fetchCertificate(Collection<String> domains) throws IOException, AcmeException {
078        // Load the user key file. If there is no key file, create a new one.
079        // Keep this key pair in a safe place! In a production environment, you will not be
080        // able to access your account again if you should lose the key pair.
081        KeyPair userKeyPair = loadOrCreateKeyPair(USER_KEY_FILE);
082
083        // Create a session for Let's Encrypt.
084        // Use "acme://letsencrypt.org" for production server
085        Session session = new Session("acme://letsencrypt.org/staging", userKeyPair);
086
087        // Get the Registration to the account.
088        // If there is no account yet, create a new one.
089        Registration reg = findOrRegisterAccount(session);
090
091        // Separately authorize every requested domain.
092        for (String domain : domains) {
093            authorize(reg, domain);
094        }
095
096        // Load or create a key pair for the domains. This should not be the userKeyPair!
097        KeyPair domainKeyPair = loadOrCreateKeyPair(DOMAIN_KEY_FILE);
098
099        // Generate a CSR for all of the domains, and sign it with the domain key pair.
100        CSRBuilder csrb = new CSRBuilder();
101        csrb.addDomains(domains);
102        csrb.sign(domainKeyPair);
103
104        // Write the CSR to a file, for later use.
105        try (Writer out = new FileWriter(DOMAIN_CSR_FILE)) {
106            csrb.write(out);
107        }
108
109        // Now request a signed certificate.
110        Certificate certificate = reg.requestCertificate(csrb.getEncoded());
111
112        LOG.info("Success! The certificate for domains " + domains + " has been generated!");
113        LOG.info("Certificate URL: " + certificate.getLocation());
114
115        // Download the leaf certificate and certificate chain.
116        X509Certificate[] fullChain = certificate.downloadFullChain();
117
118        // Write a combined file containing the certificate and chain.
119        try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
120            CertificateUtils.writeX509Certificates(fw, fullChain);
121        }
122
123        // That's all! Configure your web server to use the DOMAIN_KEY_FILE and
124        // DOMAIN_CHAIN_FILE for the requested domans.
125    }
126
127    /**
128     * Loads a key pair from specified file. If the file does not exist,
129     * a new key pair is generated and saved.
130     *
131     * @return {@link KeyPair}.
132     */
133    private KeyPair loadOrCreateKeyPair(File file) throws IOException {
134        if (file.exists()) {
135            try (FileReader fr = new FileReader(file)) {
136                return KeyPairUtils.readKeyPair(fr);
137            }
138        } else {
139            KeyPair domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
140            try (FileWriter fw = new FileWriter(file)) {
141                KeyPairUtils.writeKeyPair(domainKeyPair, fw);
142            }
143            return domainKeyPair;
144        }
145    }
146
147    /**
148     * Finds your {@link Registration} at the ACME server. It will be found by your user's
149     * public key. If your key is not known to the server yet, a new registration will be
150     * created.
151     * <p>
152     * This is a simple way of finding your {@link Registration}. A better way is to get
153     * the URL of your new registration with {@link Registration#getLocation()} and store
154     * it somewhere. If you need to get access to your account later, reconnect to it via
155     * {@link Registration#bind(Session, URL)} by using the stored location.
156     *
157     * @param session
158     *            {@link Session} to bind with
159     * @return {@link Registration} connected to your account
160     */
161    private Registration findOrRegisterAccount(Session session) throws AcmeException {
162        Registration reg;
163
164        try {
165            // Try to create a new Registration.
166            reg = new RegistrationBuilder().create(session);
167            LOG.info("Registered a new user, URL: " + reg.getLocation());
168
169            // This is a new account. Let the user accept the Terms of Service.
170            // We won't be able to authorize domains until the ToS is accepted.
171            URI agreement = reg.getAgreement();
172            LOG.info("Terms of Service: " + agreement);
173            acceptAgreement(reg, agreement);
174
175        } catch (AcmeConflictException ex) {
176            // The Key Pair is already registered. getLocation() contains the
177            // URL of the existing registration's location. Bind it to the session.
178            reg = Registration.bind(session, ex.getLocation());
179            LOG.info("Account does already exist, URL: " + reg.getLocation(), ex);
180        }
181
182        return reg;
183    }
184
185    /**
186     * Authorize a domain. It will be associated with your account, so you will be able to
187     * retrieve a signed certificate for the domain later.
188     * <p>
189     * You need separate authorizations for subdomains (e.g. "www" subdomain). Wildcard
190     * certificates are not currently supported.
191     *
192     * @param reg
193     *            {@link Registration} of your account
194     * @param domain
195     *            Name of the domain to authorize
196     */
197    private void authorize(Registration reg, String domain) throws AcmeException {
198        // Authorize the domain.
199        Authorization auth = reg.authorizeDomain(domain);
200        LOG.info("Authorization for domain " + domain);
201
202        // Find the desired challenge and prepare it.
203        Challenge challenge = null;
204        switch (CHALLENGE_TYPE) {
205            case HTTP:
206                challenge = httpChallenge(auth, domain);
207                break;
208
209            case DNS:
210                challenge = dnsChallenge(auth, domain);
211                break;
212        }
213
214        if (challenge == null) {
215            throw new AcmeException("No challenge found");
216        }
217
218        // If the challenge is already verified, there's no need to execute it again.
219        if (challenge.getStatus() == Status.VALID) {
220            return;
221        }
222
223        // Now trigger the challenge.
224        challenge.trigger();
225
226        // Poll for the challenge to complete.
227        try {
228            int attempts = 10;
229            while (challenge.getStatus() != Status.VALID && attempts-- > 0) {
230                // Did the authorization fail?
231                if (challenge.getStatus() == Status.INVALID) {
232                    throw new AcmeException("Challenge failed... Giving up.");
233                }
234
235                // Wait for a few seconds
236                Thread.sleep(3000L);
237
238                // Then update the status
239                challenge.update();
240            }
241        } catch (InterruptedException ex) {
242            LOG.error("interrupted", ex);
243            Thread.currentThread().interrupt();
244        }
245
246        // All reattempts are used up and there is still no valid authorization?
247        if (challenge.getStatus() != Status.VALID) {
248            throw new AcmeException("Failed to pass the challenge for domain " + domain + ", ... Giving up.");
249        }
250    }
251
252    /**
253     * Prepares a HTTP challenge.
254     * <p>
255     * The verification of this challenge expects a file with a certain content to be
256     * reachable at a given path under the domain to be tested.
257     * <p>
258     * This example outputs instructions that need to be executed manually. In a
259     * production environment, you would rather generate this file automatically, or maybe
260     * use a servlet that returns {@link Http01Challenge#getAuthorization()}.
261     *
262     * @param auth
263     *            {@link Authorization} to find the challenge in
264     * @param domain
265     *            Domain name to be authorized
266     * @return {@link Challenge} to verify
267     */
268    public Challenge httpChallenge(Authorization auth, String domain) throws AcmeException {
269        // Find a single http-01 challenge
270        Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE);
271        if (challenge == null) {
272            throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge, don't know what to do...");
273        }
274
275        // Output the challenge, wait for acknowledge...
276        LOG.info("Please create a file in your web server's base directory.");
277        LOG.info("It must be reachable at: http://" + domain + "/.well-known/acme-challenge/" + challenge.getToken());
278        LOG.info("File name: " + challenge.getToken());
279        LOG.info("Content: " + challenge.getAuthorization());
280        LOG.info("The file must not contain any leading or trailing whitespaces or line breaks!");
281        LOG.info("If you're ready, dismiss the dialog...");
282
283        StringBuilder message = new StringBuilder();
284        message.append("Please create a file in your web server's base directory.\n\n");
285        message.append("http://").append(domain).append("/.well-known/acme-challenge/").append(challenge.getToken()).append("\n\n");
286        message.append("Content:\n\n");
287        message.append(challenge.getAuthorization());
288        acceptChallenge(message.toString());
289
290        return challenge;
291    }
292
293    /**
294     * Prepares a DNS challenge.
295     * <p>
296     * The verification of this challenge expects a TXT record with a certain content.
297     * <p>
298     * This example outputs instructions that need to be executed manually. In a
299     * production environment, you would rather configure your DNS automatically.
300     *
301     * @param auth
302     *            {@link Authorization} to find the challenge in
303     * @param domain
304     *            Domain name to be authorized
305     * @return {@link Challenge} to verify
306     */
307    public Challenge dnsChallenge(Authorization auth, String domain) throws AcmeException {
308        // Find a single dns-01 challenge
309        Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.TYPE);
310        if (challenge == null) {
311            throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge, don't know what to do...");
312        }
313
314        // Output the challenge, wait for acknowledge...
315        LOG.info("Please create a TXT record:");
316        LOG.info("_acme-challenge." + domain + ". IN TXT " + challenge.getDigest());
317        LOG.info("If you're ready, dismiss the dialog...");
318
319        StringBuilder message = new StringBuilder();
320        message.append("Please create a TXT record:\n\n");
321        message.append("_acme-challenge." + domain + ". IN TXT " + challenge.getDigest());
322        acceptChallenge(message.toString());
323
324        return challenge;
325    }
326
327    /**
328     * Presents the instructions for preparing the challenge validation, and waits for
329     * dismissal. If the user cancelled the dialog, an exception is thrown.
330     *
331     * @param message
332     *            Instructions to be shown in the dialog
333     */
334    public void acceptChallenge(String message) throws AcmeException {
335        int option = JOptionPane.showConfirmDialog(null,
336                        message,
337                        "Prepare Challenge",
338                        JOptionPane.OK_CANCEL_OPTION);
339        if (option == JOptionPane.CANCEL_OPTION) {
340            throw new AcmeException("User cancelled the challenge");
341        }
342    }
343
344    /**
345     * Presents the user a link to the Terms of Service, and asks for confirmation. If the
346     * user denies confirmation, an exception is thrown.
347     *
348     * @param reg
349     *            {@link Registration} User's registration
350     * @param agreement
351     *            {@link URI} of the Terms of Service
352     */
353    public void acceptAgreement(Registration reg, URI agreement) throws AcmeException {
354        int option = JOptionPane.showConfirmDialog(null,
355                        "Do you accept the Terms of Service?\n\n" + agreement,
356                        "Accept ToS",
357                        JOptionPane.YES_NO_OPTION);
358        if (option == JOptionPane.NO_OPTION) {
359            throw new AcmeException("User did not accept Terms of Service");
360        }
361
362        // Motify the Registration and accept the agreement
363        reg.modify().setAgreement(agreement).commit();
364        LOG.info("Updated user's ToS");
365    }
366
367    /**
368     * Invokes this example.
369     *
370     * @param args
371     *            Domains to get a certificate for
372     */
373    public static void main(String... args) {
374        if (args.length == 0) {
375            System.err.println("Usage: ClientTest <domain>...");
376            System.exit(1);
377        }
378
379        LOG.info("Starting up...");
380
381        Security.addProvider(new BouncyCastleProvider());
382
383        Collection<String> domains = Arrays.asList(args);
384        try {
385            ClientTest ct = new ClientTest();
386            ct.fetchCertificate(domains);
387        } catch (Exception ex) {
388            LOG.error("Failed to get a certificate for domains " + domains, ex);
389        }
390    }
391
392}