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.example;
015
016import java.io.File;
017import java.io.FileReader;
018import java.io.FileWriter;
019import java.io.IOException;
020import java.net.URI;
021import java.net.URL;
022import java.security.KeyPair;
023import java.security.Security;
024import java.time.Instant;
025import java.time.temporal.ChronoUnit;
026import java.util.Arrays;
027import java.util.Collection;
028import java.util.EnumSet;
029import java.util.Optional;
030import java.util.Set;
031import java.util.function.Supplier;
032
033import javax.swing.JOptionPane;
034
035import org.bouncycastle.jce.provider.BouncyCastleProvider;
036import org.shredzone.acme4j.Account;
037import org.shredzone.acme4j.AccountBuilder;
038import org.shredzone.acme4j.Authorization;
039import org.shredzone.acme4j.Certificate;
040import org.shredzone.acme4j.Order;
041import org.shredzone.acme4j.Problem;
042import org.shredzone.acme4j.Session;
043import org.shredzone.acme4j.Status;
044import org.shredzone.acme4j.challenge.Challenge;
045import org.shredzone.acme4j.challenge.Dns01Challenge;
046import org.shredzone.acme4j.challenge.Http01Challenge;
047import org.shredzone.acme4j.exception.AcmeException;
048import org.shredzone.acme4j.util.KeyPairUtils;
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051
052/**
053 * A simple client test tool.
054 * <p>
055 * First check the configuration constants at the top of the class. Then run the class,
056 * and pass in the names of the domains as parameters.
057 * <p>
058 * The tool won't run as-is. You MUST change the {@link #CA_URI} constant and set the
059 * connection URI of your target CA there.
060 * <p>
061 * If your CA requires External Account Binding (EAB), you MUST also fill the
062 * {@link #EAB_KID} and {@link #EAB_HMAC} constants with the values provided by your CA.
063 * <p>
064 * If your CA requires an email field to be set in your account, you also need to set
065 * {@link #ACCOUNT_EMAIL}.
066 * <p>
067 * All other fields are optional and should work with the default values, unless your CA
068 * has special requirements (e.g. to the key type).
069 *
070 * @see <a href="https://shredzone.org/maven/acme4j/example.html">This example, fully
071 * explained in the documentation.</a>
072 */
073public class ClientTest {
074    // Set the Connection URI of your CA here. For testing purposes, use a staging
075    // server if possible. Example: "acme://letsencrypt.org/staging" for the Let's
076    // Encrypt staging server.
077    private static final String CA_URI = "acme://example.com/staging";
078
079    // E-Mail address to be associated with the account. Optional, null if not used.
080    private static final String ACCOUNT_EMAIL = null;
081
082    // If the CA requires External Account Binding (EAB), set the provided KID and HMAC here.
083    private static final String EAB_KID = null;
084    private static final String EAB_HMAC = null;
085
086    // A supplier for a new account KeyPair. The default creates a new EC key pair.
087    private static Supplier<KeyPair> ACCOUNT_KEY_SUPPLIER = () -> KeyPairUtils.createKeyPair();
088
089    // A supplier for a new domain KeyPair. The default creates a RSA key pair.
090    private static Supplier<KeyPair> DOMAIN_KEY_SUPPLIER = () -> KeyPairUtils.createKeyPair(4096);
091
092    // File name of the User Key Pair
093    private static final File USER_KEY_FILE = new File("user.key");
094
095    // File name of the Domain Key Pair
096    private static final File DOMAIN_KEY_FILE = new File("domain.key");
097
098    // File name of the signed certificate
099    private static final File DOMAIN_CHAIN_FILE = new File("domain-chain.crt");
100
101    //Challenge type to be used
102    private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
103
104    // Maximum attempts of status polling until VALID/INVALID is expected
105    private static final int MAX_ATTEMPTS = 50;
106
107    private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
108
109    private enum ChallengeType {HTTP, DNS}
110
111    /**
112     * Generates a certificate for the given domains. Also takes care for the registration
113     * process.
114     *
115     * @param domains
116     *         Domains to get a common certificate for
117     */
118    public void fetchCertificate(Collection<String> domains) throws IOException, AcmeException {
119        // Load the user key file. If there is no key file, create a new one.
120        KeyPair userKeyPair = loadOrCreateUserKeyPair();
121
122        // Create a session.
123        Session session = new Session(CA_URI);
124
125        // Get the Account.
126        // If there is no account yet, create a new one.
127        Account acct = findOrRegisterAccount(session, userKeyPair);
128
129        // Load or create a key pair for the domains. This should not be the userKeyPair!
130        KeyPair domainKeyPair = loadOrCreateDomainKeyPair();
131
132        // Order the certificate
133        Order order = acct.newOrder().domains(domains).create();
134
135        // Perform all required authorizations
136        for (Authorization auth : order.getAuthorizations()) {
137            authorize(auth);
138        }
139
140        // Order the certificate
141        order.execute(domainKeyPair);
142
143        // Wait for the order to complete
144        Status status = waitForCompletion(order::getStatus, order::fetch);
145        if (status != Status.VALID) {
146            LOG.error("Order has failed, reason: {}", order.getError()
147                    .map(Problem::toString)
148                    .orElse("unknown")
149            );
150            throw new AcmeException("Order failed... Giving up.");
151        }
152
153        // Get the certificate
154        Certificate certificate = order.getCertificate();
155
156        LOG.info("Success! The certificate for domains {} has been generated!", domains);
157        LOG.info("Certificate URL: {}", certificate.getLocation());
158
159        // Write a combined file containing the certificate and chain.
160        try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
161            certificate.writeCertificate(fw);
162        }
163
164        // That's all! Configure your web server to use the DOMAIN_KEY_FILE and
165        // DOMAIN_CHAIN_FILE for the requested domains.
166    }
167
168    /**
169     * Loads a user key pair from {@link #USER_KEY_FILE}. If the file does not exist, a
170     * new key pair is generated and saved.
171     * <p>
172     * Keep this key pair in a safe place! In a production environment, you will not be
173     * able to access your account again if you should lose the key pair.
174     *
175     * @return User's {@link KeyPair}.
176     */
177    private KeyPair loadOrCreateUserKeyPair() throws IOException {
178        if (USER_KEY_FILE.exists()) {
179            // If there is a key file, read it
180            try (FileReader fr = new FileReader(USER_KEY_FILE)) {
181                return KeyPairUtils.readKeyPair(fr);
182            }
183
184        } else {
185            // If there is none, create a new key pair and save it
186            KeyPair userKeyPair = ACCOUNT_KEY_SUPPLIER.get();
187            try (FileWriter fw = new FileWriter(USER_KEY_FILE)) {
188                KeyPairUtils.writeKeyPair(userKeyPair, fw);
189            }
190            return userKeyPair;
191        }
192    }
193
194    /**
195     * Loads a domain key pair from {@link #DOMAIN_KEY_FILE}. If the file does not exist,
196     * a new key pair is generated and saved.
197     *
198     * @return Domain {@link KeyPair}.
199     */
200    private KeyPair loadOrCreateDomainKeyPair() throws IOException {
201        if (DOMAIN_KEY_FILE.exists()) {
202            try (FileReader fr = new FileReader(DOMAIN_KEY_FILE)) {
203                return KeyPairUtils.readKeyPair(fr);
204            }
205        } else {
206            KeyPair domainKeyPair = DOMAIN_KEY_SUPPLIER.get();
207            try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) {
208                KeyPairUtils.writeKeyPair(domainKeyPair, fw);
209            }
210            return domainKeyPair;
211        }
212    }
213
214    /**
215     * Finds your {@link Account} at the ACME server. It will be found by your user's
216     * public key. If your key is not known to the server yet, a new account will be
217     * created.
218     * <p>
219     * This is a simple way of finding your {@link Account}. A better way is to get the
220     * URL of your new account with {@link Account#getLocation()} and store it somewhere.
221     * If you need to get access to your account later, reconnect to it via {@link
222     * Session#login(URL, KeyPair)} by using the stored location.
223     *
224     * @param session
225     *         {@link Session} to bind with
226     * @return {@link Account}
227     */
228    private Account findOrRegisterAccount(Session session, KeyPair accountKey) throws AcmeException {
229        // Ask the user to accept the TOS, if server provides us with a link.
230        Optional<URI> tos = session.getMetadata().getTermsOfService();
231        if (tos.isPresent()) {
232            acceptAgreement(tos.get());
233        }
234
235        AccountBuilder accountBuilder = new AccountBuilder()
236                .agreeToTermsOfService()
237                .useKeyPair(accountKey);
238
239        // Set your email (if available)
240        if (ACCOUNT_EMAIL != null) {
241            accountBuilder.addEmail(ACCOUNT_EMAIL);
242        }
243
244        // Use the KID and HMAC if the CA uses External Account Binding
245        if (EAB_KID != null && EAB_HMAC != null) {
246            accountBuilder.withKeyIdentifier(EAB_KID, EAB_HMAC);
247        }
248
249        Account account = accountBuilder.create(session);
250        LOG.info("Registered a new user, URL: {}", account.getLocation());
251
252        return account;
253    }
254
255    /**
256     * Authorize a domain. It will be associated with your account, so you will be able to
257     * retrieve a signed certificate for the domain later.
258     *
259     * @param auth
260     *         {@link Authorization} to perform
261     */
262    private void authorize(Authorization auth) throws AcmeException {
263        LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
264
265        // The authorization is already valid. No need to process a challenge.
266        if (auth.getStatus() == Status.VALID) {
267            return;
268        }
269
270        // Find the desired challenge and prepare it.
271        Challenge challenge = null;
272        switch (CHALLENGE_TYPE) {
273            case HTTP:
274                challenge = httpChallenge(auth);
275                break;
276
277            case DNS:
278                challenge = dnsChallenge(auth);
279                break;
280        }
281
282        if (challenge == null) {
283            throw new AcmeException("No challenge found");
284        }
285
286        // If the challenge is already verified, there's no need to execute it again.
287        if (challenge.getStatus() == Status.VALID) {
288            return;
289        }
290
291        // Now trigger the challenge.
292        challenge.trigger();
293
294        // Poll for the challenge to complete.
295        Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
296        if (status != Status.VALID) {
297            LOG.error("Challenge has failed, reason: {}", challenge.getError()
298                    .map(Problem::toString)
299                    .orElse("unknown"));
300            throw new AcmeException("Challenge failed... Giving up.");
301        }
302
303        LOG.info("Challenge has been completed. Remember to remove the validation resource.");
304        completeChallenge("Challenge has been completed.\nYou can remove the resource again now.");
305    }
306
307    /**
308     * Prepares a HTTP challenge.
309     * <p>
310     * The verification of this challenge expects a file with a certain content to be
311     * reachable at a given path under the domain to be tested.
312     * <p>
313     * This example outputs instructions that need to be executed manually. In a
314     * production environment, you would rather generate this file automatically, or maybe
315     * use a servlet that returns {@link Http01Challenge#getAuthorization()}.
316     *
317     * @param auth
318     *         {@link Authorization} to find the challenge in
319     * @return {@link Challenge} to verify
320     */
321    public Challenge httpChallenge(Authorization auth) throws AcmeException {
322        // Find a single http-01 challenge
323        Http01Challenge challenge = auth.findChallenge(Http01Challenge.class)
324                .orElseThrow(() -> new AcmeException("Found no " + Http01Challenge.TYPE
325                        + " challenge, don't know what to do..."));
326
327        // Output the challenge, wait for acknowledge...
328        LOG.info("Please create a file in your web server's base directory.");
329        LOG.info("It must be reachable at: http://{}/.well-known/acme-challenge/{}",
330                auth.getIdentifier().getDomain(), challenge.getToken());
331        LOG.info("File name: {}", challenge.getToken());
332        LOG.info("Content: {}", challenge.getAuthorization());
333        LOG.info("The file must not contain any leading or trailing whitespaces or line breaks!");
334        LOG.info("If you're ready, dismiss the dialog...");
335
336        StringBuilder message = new StringBuilder();
337        message.append("Please create a file in your web server's base directory.\n\n");
338        message.append("http://")
339                .append(auth.getIdentifier().getDomain())
340                .append("/.well-known/acme-challenge/")
341                .append(challenge.getToken())
342                .append("\n\n");
343        message.append("Content:\n\n");
344        message.append(challenge.getAuthorization());
345        acceptChallenge(message.toString());
346
347        return challenge;
348    }
349
350    /**
351     * Prepares a DNS challenge.
352     * <p>
353     * The verification of this challenge expects a TXT record with a certain content.
354     * <p>
355     * This example outputs instructions that need to be executed manually. In a
356     * production environment, you would rather configure your DNS automatically.
357     *
358     * @param auth
359     *         {@link Authorization} to find the challenge in
360     * @return {@link Challenge} to verify
361     */
362    public Challenge dnsChallenge(Authorization auth) throws AcmeException {
363        // Find a single dns-01 challenge
364        Dns01Challenge challenge = auth.findChallenge(Dns01Challenge.TYPE)
365                .map(Dns01Challenge.class::cast)
366                .orElseThrow(() -> new AcmeException("Found no " + Dns01Challenge.TYPE
367                        + " challenge, don't know what to do..."));
368
369        // Output the challenge, wait for acknowledge...
370        LOG.info("Please create a TXT record:");
371        LOG.info("{} IN TXT {}",
372                Dns01Challenge.toRRName(auth.getIdentifier()), challenge.getDigest());
373        LOG.info("If you're ready, dismiss the dialog...");
374
375        StringBuilder message = new StringBuilder();
376        message.append("Please create a TXT record:\n\n");
377        message.append(Dns01Challenge.toRRName(auth.getIdentifier()))
378                .append(" IN TXT ")
379                .append(challenge.getDigest());
380        acceptChallenge(message.toString());
381
382        return challenge;
383    }
384
385    /**
386     * Waits for completion of a resource. A resource is completed if the status is either
387     * {@link Status#VALID} or {@link Status#INVALID}.
388     * <p>
389     * This method polls the current status, respecting the retry-after header if set. It
390     * is synchronous and may take a considerable time for completion.
391     * <p>
392     * It is meant as a simple example! For production services, it is recommended to do
393     * an asynchronous processing here.
394     *
395     * @param statusSupplier
396     *         Method of the resource that returns the current status
397     * @param statusUpdater
398     *         Method of the resource that updates the internal state and fetches the
399     *         current status from the server. It returns the instant of an optional
400     *         retry-after header.
401     * @return The final status, either {@link Status#VALID} or {@link Status#INVALID}
402     * @throws AcmeException
403     *         If an error occured, or if the status did not reach one of the accepted
404     *         result values after a certain number of checks.
405     */
406    private Status waitForCompletion(Supplier<Status> statusSupplier, UpdateMethod statusUpdater)
407            throws AcmeException {
408        // A set of terminating status values
409        Set<Status> acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
410
411        // Limit the number of checks, to avoid endless loops
412        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
413            LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
414
415            Instant now = Instant.now();
416
417            // Update the status property
418            Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
419                    .orElse(now.plusSeconds(3L));
420
421            // Check the status
422            Status currentStatus = statusSupplier.get();
423            if (acceptableStatus.contains(currentStatus)) {
424                // Reached VALID or INVALID, we're done here
425                return currentStatus;
426            }
427
428            // Wait before checking again
429            try {
430                Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
431            } catch (InterruptedException ex) {
432                Thread.currentThread().interrupt();
433                throw new AcmeException("interrupted");
434            }
435        }
436
437        throw new AcmeException("Too many update attempts, status did not change");
438    }
439
440    /**
441     * Functional interface that refers to a resource update method that returns an
442     * optional retry-after instant and is able to throw an {@link AcmeException}.
443     */
444    @FunctionalInterface
445    private interface UpdateMethod {
446        Optional<Instant> updateAndGetRetryAfter() throws AcmeException;
447    }
448
449    /**
450     * Presents the instructions for preparing the challenge validation, and waits for
451     * dismissal. If the user cancelled the dialog, an exception is thrown.
452     *
453     * @param message
454     *         Instructions to be shown in the dialog
455     */
456    public void acceptChallenge(String message) throws AcmeException {
457        int option = JOptionPane.showConfirmDialog(null,
458                message,
459                "Prepare Challenge",
460                JOptionPane.OK_CANCEL_OPTION);
461        if (option == JOptionPane.CANCEL_OPTION) {
462            throw new AcmeException("User cancelled the challenge");
463        }
464    }
465
466    /**
467     * Presents the instructions for removing the challenge validation, and waits for
468     * dismissal.
469     *
470     * @param message
471     *         Instructions to be shown in the dialog
472     */
473    public void completeChallenge(String message) {
474        JOptionPane.showMessageDialog(null,
475                message,
476                "Complete Challenge",
477                JOptionPane.INFORMATION_MESSAGE);
478    }
479
480    /**
481     * Presents the user a link to the Terms of Service, and asks for confirmation. If the
482     * user denies confirmation, an exception is thrown.
483     *
484     * @param agreement
485     *         {@link URI} of the Terms of Service
486     */
487    public void acceptAgreement(URI agreement) throws AcmeException {
488        int option = JOptionPane.showConfirmDialog(null,
489                "Do you accept the Terms of Service?\n\n" + agreement,
490                "Accept ToS",
491                JOptionPane.YES_NO_OPTION);
492        if (option == JOptionPane.NO_OPTION) {
493            throw new AcmeException("User did not accept Terms of Service");
494        }
495    }
496
497    /**
498     * Invokes this example.
499     *
500     * @param args
501     *         Domains to get a certificate for
502     */
503    public static void main(String... args) {
504        if (args.length == 0) {
505            System.err.println("Usage: ClientTest <domain>...");
506            System.exit(1);
507        }
508
509        LOG.info("Starting up...");
510
511        Security.addProvider(new BouncyCastleProvider());
512
513        Collection<String> domains = Arrays.asList(args);
514        try {
515            ClientTest ct = new ClientTest();
516            ct.fetchCertificate(domains);
517        } catch (Exception ex) {
518            LOG.error("Failed to get a certificate for domains " + domains, ex);
519        }
520    }
521
522}