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}