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}