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.util.Arrays;
025import java.util.Collection;
026
027import javax.swing.JOptionPane;
028
029import org.bouncycastle.jce.provider.BouncyCastleProvider;
030import org.shredzone.acme4j.challenge.Challenge;
031import org.shredzone.acme4j.challenge.Dns01Challenge;
032import org.shredzone.acme4j.challenge.Http01Challenge;
033import org.shredzone.acme4j.exception.AcmeException;
034import org.shredzone.acme4j.util.CSRBuilder;
035import org.shredzone.acme4j.util.KeyPairUtils;
036import org.slf4j.Logger;
037import org.slf4j.LoggerFactory;
038
039/**
040 * A simple client test tool.
041 * <p>
042 * Pass the names of the domains as parameters.
043 */
044public class ClientTest {
045    // File name of the User Key Pair
046    private static final File USER_KEY_FILE = new File("user.key");
047
048    // File name of the Domain Key Pair
049    private static final File DOMAIN_KEY_FILE = new File("domain.key");
050
051    // File name of the CSR
052    private static final File DOMAIN_CSR_FILE = new File("domain.csr");
053
054    // File name of the signed certificate
055    private static final File DOMAIN_CHAIN_FILE = new File("domain-chain.crt");
056
057    //Challenge type to be used
058    private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
059
060    // RSA key size of generated key pairs
061    private static final int KEY_SIZE = 2048;
062
063    private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
064
065    private enum ChallengeType { HTTP, DNS }
066
067    /**
068     * Generates a certificate for the given domains. Also takes care for the registration
069     * process.
070     *
071     * @param domains
072     *            Domains to get a common certificate for
073     */
074    public void fetchCertificate(Collection<String> domains) throws IOException, AcmeException {
075        // Load the user key file. If there is no key file, create a new one.
076        KeyPair userKeyPair = loadOrCreateUserKeyPair();
077
078        // Create a session for Let's Encrypt.
079        // Use "acme://letsencrypt.org" for production server
080        Session session = new Session("acme://letsencrypt.org/staging");
081
082        // Get the Account.
083        // If there is no account yet, create a new one.
084        Account acct = findOrRegisterAccount(session, userKeyPair);
085
086        // Load or create a key pair for the domains. This should not be the userKeyPair!
087        KeyPair domainKeyPair = loadOrCreateDomainKeyPair();
088
089        // Order the certificate
090        Order order = acct.newOrder().domains(domains).create();
091
092        // Perform all required authorizations
093        for (Authorization auth : order.getAuthorizations()) {
094            authorize(auth);
095        }
096
097        // Generate a CSR for all of the domains, and sign it with the domain key pair.
098        CSRBuilder csrb = new CSRBuilder();
099        csrb.addDomains(domains);
100        csrb.sign(domainKeyPair);
101
102        // Write the CSR to a file, for later use.
103        try (Writer out = new FileWriter(DOMAIN_CSR_FILE)) {
104            csrb.write(out);
105        }
106
107        // Order the certificate
108        order.execute(csrb.getEncoded());
109
110        // Wait for the order to complete
111        try {
112            int attempts = 10;
113            while (order.getStatus() != Status.VALID && attempts-- > 0) {
114                // Did the order fail?
115                if (order.getStatus() == Status.INVALID) {
116                    throw new AcmeException("Order failed... Giving up.");
117                }
118
119                // Wait for a few seconds
120                Thread.sleep(3000L);
121
122                // Then update the status
123                order.update();
124            }
125        } catch (InterruptedException ex) {
126            LOG.error("interrupted", ex);
127            Thread.currentThread().interrupt();
128        }
129
130        // Get the certificate
131        Certificate certificate = order.getCertificate();
132
133        LOG.info("Success! The certificate for domains {} has been generated!", domains);
134        LOG.info("Certificate URL: {}", certificate.getLocation());
135
136        // Write a combined file containing the certificate and chain.
137        try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
138            certificate.writeCertificate(fw);
139        }
140
141        // That's all! Configure your web server to use the DOMAIN_KEY_FILE and
142        // DOMAIN_CHAIN_FILE for the requested domans.
143    }
144
145    /**
146     * Loads a user key pair from {@value #USER_KEY_FILE}. If the file does not exist,
147     * a new key pair is generated and saved.
148     * <p>
149     * Keep this key pair in a safe place! In a production environment, you will not be
150     * able to access your account again if you should lose the key pair.
151     *
152     * @return User's {@link KeyPair}.
153     */
154    private KeyPair loadOrCreateUserKeyPair() throws IOException {
155        if (USER_KEY_FILE.exists()) {
156            // If there is a key file, read it
157            try (FileReader fr = new FileReader(USER_KEY_FILE)) {
158                return KeyPairUtils.readKeyPair(fr);
159            }
160
161        } else {
162            // If there is none, create a new key pair and save it
163            KeyPair userKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
164            try (FileWriter fw = new FileWriter(USER_KEY_FILE)) {
165                KeyPairUtils.writeKeyPair(userKeyPair, fw);
166            }
167            return userKeyPair;
168        }
169    }
170
171    /**
172     * Loads a domain key pair from {@value #DOMAIN_KEY_FILE}. If the file does not exist,
173     * a new key pair is generated and saved.
174     *
175     * @return Domain {@link KeyPair}.
176     */
177    private KeyPair loadOrCreateDomainKeyPair() throws IOException {
178        if (DOMAIN_KEY_FILE.exists()) {
179            try (FileReader fr = new FileReader(DOMAIN_KEY_FILE)) {
180                return KeyPairUtils.readKeyPair(fr);
181            }
182        } else {
183            KeyPair domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
184            try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) {
185                KeyPairUtils.writeKeyPair(domainKeyPair, fw);
186            }
187            return domainKeyPair;
188        }
189    }
190
191    /**
192     * Finds your {@link Account} at the ACME server. It will be found by your user's
193     * public key. If your key is not known to the server yet, a new account will be
194     * created.
195     * <p>
196     * This is a simple way of finding your {@link Account}. A better way is to get the
197     * URL and KeyIdentifier of your new account with {@link Account#getLocation()}
198     * {@link Session#getKeyIdentifier()} and store it somewhere. If you need to get
199     * access to your account later, reconnect to it via
200     * {@link Account#bind(Session, URI)} by using the stored location.
201     *
202     * @param session
203     *            {@link Session} to bind with
204     * @return {@link Login} that is connected to your account
205     */
206    private Account findOrRegisterAccount(Session session, KeyPair accountKey) throws AcmeException {
207        // Ask the user to accept the TOS, if server provides us with a link.
208        URI tos = session.getMetadata().getTermsOfService();
209        if (tos != null) {
210            acceptAgreement(tos);
211        }
212
213        Account account = new AccountBuilder()
214                        .agreeToTermsOfService()
215                        .useKeyPair(accountKey)
216                        .create(session);
217        LOG.info("Registered a new user, URL: {}", account.getLocation());
218
219        return account;
220    }
221
222    /**
223     * Authorize a domain. It will be associated with your account, so you will be able to
224     * retrieve a signed certificate for the domain later.
225     *
226     * @param auth
227     *            {@link Authorization} to perform
228     */
229    private void authorize(Authorization auth) throws AcmeException {
230        LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
231
232        // The authorization is already valid. No need to process a challenge.
233        if (auth.getStatus() == Status.VALID) {
234            return;
235        }
236
237        // Find the desired challenge and prepare it.
238        Challenge challenge = null;
239        switch (CHALLENGE_TYPE) {
240            case HTTP:
241                challenge = httpChallenge(auth);
242                break;
243
244            case DNS:
245                challenge = dnsChallenge(auth);
246                break;
247        }
248
249        if (challenge == null) {
250            throw new AcmeException("No challenge found");
251        }
252
253        // If the challenge is already verified, there's no need to execute it again.
254        if (challenge.getStatus() == Status.VALID) {
255            return;
256        }
257
258        // Now trigger the challenge.
259        challenge.trigger();
260
261        // Poll for the challenge to complete.
262        try {
263            int attempts = 10;
264            while (challenge.getStatus() != Status.VALID && attempts-- > 0) {
265                // Did the authorization fail?
266                if (challenge.getStatus() == Status.INVALID) {
267                    throw new AcmeException("Challenge failed... Giving up.");
268                }
269
270                // Wait for a few seconds
271                Thread.sleep(3000L);
272
273                // Then update the status
274                challenge.update();
275            }
276        } catch (InterruptedException ex) {
277            LOG.error("interrupted", ex);
278            Thread.currentThread().interrupt();
279        }
280
281        // All reattempts are used up and there is still no valid authorization?
282        if (challenge.getStatus() != Status.VALID) {
283            throw new AcmeException("Failed to pass the challenge for domain "
284                    + auth.getIdentifier().getDomain() + ", ... Giving up.");
285        }
286
287        LOG.info("Challenge has been completed. Remember to remove the validation resource.");
288        completeChallenge("Challenge has been completed.\nYou can remove the resource again now.");
289    }
290
291    /**
292     * Prepares a HTTP challenge.
293     * <p>
294     * The verification of this challenge expects a file with a certain content to be
295     * reachable at a given path under the domain to be tested.
296     * <p>
297     * This example outputs instructions that need to be executed manually. In a
298     * production environment, you would rather generate this file automatically, or maybe
299     * use a servlet that returns {@link Http01Challenge#getAuthorization()}.
300     *
301     * @param auth
302     *            {@link Authorization} to find the challenge in
303     * @return {@link Challenge} to verify
304     */
305    public Challenge httpChallenge(Authorization auth) throws AcmeException {
306        // Find a single http-01 challenge
307        Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE);
308        if (challenge == null) {
309            throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge, don't know what to do...");
310        }
311
312        // Output the challenge, wait for acknowledge...
313        LOG.info("Please create a file in your web server's base directory.");
314        LOG.info("It must be reachable at: http://{}/.well-known/acme-challenge/{}",
315                    auth.getIdentifier().getDomain(), challenge.getToken());
316        LOG.info("File name: {}", challenge.getToken());
317        LOG.info("Content: {}", challenge.getAuthorization());
318        LOG.info("The file must not contain any leading or trailing whitespaces or line breaks!");
319        LOG.info("If you're ready, dismiss the dialog...");
320
321        StringBuilder message = new StringBuilder();
322        message.append("Please create a file in your web server's base directory.\n\n");
323        message.append("http://")
324                    .append(auth.getIdentifier().getDomain())
325                    .append("/.well-known/acme-challenge/")
326                    .append(challenge.getToken())
327                    .append("\n\n");
328        message.append("Content:\n\n");
329        message.append(challenge.getAuthorization());
330        acceptChallenge(message.toString());
331
332        return challenge;
333    }
334
335    /**
336     * Prepares a DNS challenge.
337     * <p>
338     * The verification of this challenge expects a TXT record with a certain content.
339     * <p>
340     * This example outputs instructions that need to be executed manually. In a
341     * production environment, you would rather configure your DNS automatically.
342     *
343     * @param auth
344     *            {@link Authorization} to find the challenge in
345     * @return {@link Challenge} to verify
346     */
347    public Challenge dnsChallenge(Authorization auth) throws AcmeException {
348        // Find a single dns-01 challenge
349        Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.TYPE);
350        if (challenge == null) {
351            throw new AcmeException("Found no " + Dns01Challenge.TYPE + " challenge, don't know what to do...");
352        }
353
354        // Output the challenge, wait for acknowledge...
355        LOG.info("Please create a TXT record:");
356        LOG.info("_acme-challenge.{}. IN TXT {}",
357                    auth.getIdentifier().getDomain(), challenge.getDigest());
358        LOG.info("If you're ready, dismiss the dialog...");
359
360        StringBuilder message = new StringBuilder();
361        message.append("Please create a TXT record:\n\n");
362        message.append("_acme-challenge.")
363                    .append(auth.getIdentifier().getDomain())
364                    .append(". IN TXT ")
365                    .append(challenge.getDigest());
366        acceptChallenge(message.toString());
367
368        return challenge;
369    }
370
371    /**
372     * Presents the instructions for preparing the challenge validation, and waits for
373     * dismissal. If the user cancelled the dialog, an exception is thrown.
374     *
375     * @param message
376     *            Instructions to be shown in the dialog
377     */
378    public void acceptChallenge(String message) throws AcmeException {
379        int option = JOptionPane.showConfirmDialog(null,
380                        message,
381                        "Prepare Challenge",
382                        JOptionPane.OK_CANCEL_OPTION);
383        if (option == JOptionPane.CANCEL_OPTION) {
384            throw new AcmeException("User cancelled the challenge");
385        }
386    }
387
388    /**
389     * Presents the instructions for removing the challenge validation, and waits for
390     * dismissal.
391     *
392     * @param message
393     *            Instructions to be shown in the dialog
394     */
395    public void completeChallenge(String message) throws AcmeException {
396        JOptionPane.showMessageDialog(null,
397                        message,
398                        "Complete Challenge",
399                        JOptionPane.INFORMATION_MESSAGE);
400    }
401
402    /**
403     * Presents the user a link to the Terms of Service, and asks for confirmation. If the
404     * user denies confirmation, an exception is thrown.
405     *
406     * @param agreement
407     *            {@link URI} of the Terms of Service
408     */
409    public void acceptAgreement(URI agreement) throws AcmeException {
410        int option = JOptionPane.showConfirmDialog(null,
411                        "Do you accept the Terms of Service?\n\n" + agreement,
412                        "Accept ToS",
413                        JOptionPane.YES_NO_OPTION);
414        if (option == JOptionPane.NO_OPTION) {
415            throw new AcmeException("User did not accept Terms of Service");
416        }
417    }
418
419    /**
420     * Invokes this example.
421     *
422     * @param args
423     *            Domains to get a certificate for
424     */
425    public static void main(String... args) {
426        if (args.length == 0) {
427            System.err.println("Usage: ClientTest <domain>...");
428            System.exit(1);
429        }
430
431        LOG.info("Starting up...");
432
433        Security.addProvider(new BouncyCastleProvider());
434
435        Collection<String> domains = Arrays.asList(args);
436        try {
437            ClientTest ct = new ClientTest();
438            ct.fetchCertificate(domains);
439        } catch (Exception ex) {
440            LOG.error("Failed to get a certificate for domains " + domains, ex);
441        }
442    }
443
444}