package cc.blynk.server.acme;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.shredzone.acme4j.*;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeConflictException;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.CSRBuilder;
import org.shredzone.acme4j.util.CertificateUtils;
import org.shredzone.acme4j.util.KeyPairUtils;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URI;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
/**
* A simple client test tool.
* <p>
* Pass the names of the domains as parameters.
*/
public class AcmeClient {
private static final Logger log = LogManager.getLogger(AcmeClient.class);
// File name of the User Key Pair
public static final File USER_KEY_FILE = new File("user.pem");
// File name of the Domain Key Pair
public static final File DOMAIN_KEY_FILE = new File("privkey.pem");
// File name of the signed certificate
public static final File DOMAIN_CHAIN_FILE = new File("fullchain.crt");
private static final String PRODUCTION = "acme://letsencrypt.org";
// RSA key size of generated key pairs
private static final int KEY_SIZE = 2048;
private final String letsEncryptUrl;
private final String email;
private final String host;
private final ContentHolder contentHolder;
public AcmeClient(String email, String host, ContentHolder contentHolder) {
this(PRODUCTION, email, host, contentHolder);
}
public AcmeClient(String letsEncryptUrl, String email, String host, ContentHolder contentHolder) {
this.letsEncryptUrl = letsEncryptUrl;
this.email = email;
this.host = host;
this.contentHolder = contentHolder;
}
public boolean requestCertificate() throws Exception {
log.info("Starting up certificate retrieval process for host {} and email {}.", host, email);
return fetchCertificate(email, host);
}
/**
* Generates a certificate for the given domains. Also takes care for the registration
* process.
*
* @param domain
* Domains to get a common certificate for
*/
public boolean fetchCertificate(String contact, String domain) throws IOException, AcmeException {
// Load the user key file. If there is no key file, create a new one.
// Keep this key pair in a safe place! In a production environment, you will not be
// able to access your account again if you should lose the key pair.
KeyPair userKeyPair = loadOrCreateKeyPair(USER_KEY_FILE);
Session session = new Session(letsEncryptUrl, userKeyPair);
// Get the Registration to the account.
// If there is no account yet, create a new one.
Registration reg = findOrRegisterAccount(session, contact);
// Separately authorize every requested domain.
authorize(reg, domain);
// Load or create a key pair for the domains. This should not be the userKeyPair!
KeyPair domainKeyPair = loadOrCreateKeyPair(DOMAIN_KEY_FILE);
// Generate a CSR for all of the domains, and sign it with the domain key pair.
CSRBuilder csrb = new CSRBuilder();
csrb.addDomain(domain);
csrb.setOrganization("Blynk Inc.");
csrb.sign(domainKeyPair);
// Write the CSR to a file, for later use.
//try (Writer out = new FileWriter(DOMAIN_CSR_FILE)) {
// csrb.write(out);
//}
// Now request a signed certificate.
Certificate certificate = reg.requestCertificate(csrb.getEncoded());
// Download the leaf certificate and certificate chain.
X509Certificate cert = certificate.download();
X509Certificate[] chain = certificate.downloadChain();
// Write a combined file containing the certificate and chain.
try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
CertificateUtils.writeX509CertificateChain(fw, cert, chain);
}
return true;
}
/**
* Loads a key pair from specified file. If the file does not exist,
* a new key pair is generated and saved.
*
* @return {@link KeyPair}.
*/
private KeyPair loadOrCreateKeyPair(File file) throws IOException {
if (file.exists()) {
try (FileReader fr = new FileReader(file)) {
return KeyPairUtils.readKeyPair(fr);
}
} else {
KeyPair domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE);
try (FileWriter fw = new FileWriter(file)) {
KeyPairUtils.writeKeyPair(domainKeyPair, fw);
}
return domainKeyPair;
}
}
/**
* Finds your {@link Registration} at the ACME server. It will be found by your user's
* public key. If your key is not known to the server yet, a new registration will be
* created.
* <p>
* This is a simple way of finding your {@link Registration}. A better way is to get
* the URI of your new registration with {@link Registration#getLocation()} and store
* it somewhere. If you need to get access to your account later, reconnect to it via
* {@link Registration#bind(Session, URI)} by using the stored location.
*
* @param session
* {@link Session} to bind with
* @return {@link Registration} connected to your account
*/
private Registration findOrRegisterAccount(Session session, String contact) throws AcmeException {
Registration reg;
try {
// Try to create a new Registration.
reg = new RegistrationBuilder().addContact("mailto:" + contact).create(session);
log.info("Registered a new user, URI: " + reg.getLocation());
// This is a new account. Let the user accept the Terms of Service.
// We won't be able to authorize domains until the ToS is accepted.
URI agreement = reg.getAgreement();
reg.modify().setAgreement(agreement).commit();
} catch (AcmeConflictException ex) {
// The Key Pair is already registered. getLocation() contains the
// URL of the existing registration's location. Bind it to the session.
reg = Registration.bind(session, ex.getLocation());
log.info("Account does already exist, URI: " + reg.getLocation());
log.debug(ex);
}
return reg;
}
/**
* Authorize a domain. It will be associated with your account, so you will be able to
* retrieve a signed certificate for the domain later.
* <p>
* You need separate authorizations for subdomains (e.g. "www" subdomain). Wildcard
* certificates are not currently supported.
*
* @param reg
* {@link Registration} of your account
* @param domain
* Name of the domain to authorize
*/
private void authorize(Registration reg, String domain) throws AcmeException {
// Authorize the domain.
Authorization auth = reg.authorizeDomain(domain);
log.info("Authorization for domain " + domain);
// Find the desired challenge and prepare it.
Http01Challenge challenge = httpChallenge(auth, domain);
contentHolder.content = challenge.getAuthorization();
// If the challenge is already verified, there's no need to execute it again.
if (challenge.getStatus() == Status.VALID) {
return;
}
// Now trigger the challenge.
challenge.trigger();
// Poll for the challenge to complete.
try {
int attempts = 10;
while (challenge.getStatus() != Status.VALID && attempts-- > 0) {
// Did the authorization fail?
if (challenge.getStatus() == Status.INVALID) {
throw new AcmeException("Challenge failed... Giving up.");
}
// Wait for a few seconds
Thread.sleep(3000L);
// Then update the status
challenge.update();
}
} catch (InterruptedException ex) {
log.error("interrupted", ex);
Thread.currentThread().interrupt();
}
// All reattempts are used up and there is still no valid authorization?
if (challenge.getStatus() != Status.VALID) {
throw new AcmeException("Failed to pass the challenge for domain " + domain + ", ... Giving up.");
}
}
/**
* Prepares a HTTP challenge.
* <p>
* The verification of this challenge expects a file with a certain content to be
* reachable at a given path under the domain to be tested.
* <p>
* This example outputs instructions that need to be executed manually. In a
* production environment, you would rather generate this file automatically, or maybe
* use a servlet that returns {@link Http01Challenge#getAuthorization()}.
*
* @param auth
* {@link Authorization} to find the challenge in
* @param domain
* Domain name to be authorized
* @return {@link Challenge} to verify
*/
public Http01Challenge httpChallenge(Authorization auth, String domain) throws AcmeException {
// Find a single http-01 challenge
Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE);
if (challenge == null) {
throw new AcmeException("Found no " + Http01Challenge.TYPE + " challenge, don't know what to do...");
}
// Output the challenge, wait for acknowledge...
log.debug("http://{}/.well-known/acme-challenge/{}", domain, challenge.getToken());
log.debug("Content: {}", challenge.getAuthorization());
return challenge;
}
}