it.zero11.acme.Acme.java Source code

Java tutorial

Introduction

Here is the source code for it.zero11.acme.Acme.java

Source

/**
 * Copyright (C) 2015 Zero11 S.r.l.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package it.zero11.acme;

import it.zero11.acme.storage.CertificateStorage;
import it.zero11.acme.utils.JWKUtils;
import it.zero11.acme.utils.X509Utils;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.security.KeyManagementException;
import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.TreeMap;
import java.util.logging.Logger;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;

import org.bouncycastle.jce.provider.X509CertParser;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.x509.util.StreamParsingException;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.TextCodec;

public class Acme {
    private static final String AGREEMENT_KEY = "agreement";
    private static final String CHALLENGE_STATUS_KEY = "status";
    private static final String CHALLENGE_STATUS_PENDING = "pending";
    private static final String CHALLENGE_STATUS_VALID = "valid";
    private static final String CHALLENGE_TLS_KEY = "tls";
    private static final String CHALLENGE_TOKEN_KEY = "token";
    private static final String CHALLENGE_KEY_AUTHORIZATION_KEY = "keyAuthorization";
    private static final String CHALLENGE_TYPE_KEY = "type";
    private static final String CHALLENGE_TYPE_HTTP_01 = "http-01";
    private static final String CHALLENGE_URI_KEY = "uri";
    private static final String CHALLENGES_KEY = "challenges";
    private static final String CONTACT_KEY = "contact";
    private static final String CSR_KEY = "csr";
    private static final String HEADER_REPLAY_NONCE = "replay-nonce";
    private static final String IDENTIFIER_KEY = "identifier";
    private static final String IDENTIFIER_TYPE_DNS = "dns";
    private static final String IDENTIFIER_TYPE_KEY = "type";
    private static final String IDENTIFIER_VALUE_KEY = "value";
    private static final String NONCE_KEY = "nonce";
    private static final String RESOURCE_CHALLENGE = "challenge";
    private static final String RESOURCE_KEY = "resource";
    private static final String RESOURCE_NEW_AUTHZ = "new-authz";
    private static final String RESOURCE_NEW_CERT = "new-cert";
    private static final String RESOURCE_NEW_REG = "new-reg";
    private static final String RESOURCE_UPDATE_REGISTRATION = "reg";

    private static SSLContext getTrustAllCertificateSSLContext()
            throws NoSuchAlgorithmException, KeyManagementException {
        TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
            @Override
            public void checkClientTrusted(X509Certificate[] certs, String authType) {
            }

            @Override
            public void checkServerTrusted(X509Certificate[] certs, String authType) {
            }

            @Override
            public X509Certificate[] getAcceptedIssuers() {
                return new X509Certificate[0];
            }
        } };

        SSLContext sc = SSLContext.getInstance("SSL");
        sc.init(null, trustAllCerts, new SecureRandom());
        return sc;
    }

    private final CertificateStorage certificateStorage;
    private final String certificationAuthorityURI;
    private final boolean trustAllCertificate;
    private final boolean debugHttpRequests;

    public Acme(String certificationAuthorityURI, CertificateStorage certificateStorage) {
        this(certificationAuthorityURI, certificateStorage, false, false);
    }

    public Acme(String certificationAuthorityURI, CertificateStorage certificateStorage,
            boolean trustAllCertificate) {
        this(certificationAuthorityURI, certificateStorage, trustAllCertificate, false);
    }

    public Acme(String certificationAuthorityURI, CertificateStorage certificateStorage,
            boolean trustAllCertificate, boolean debugHttpRequests) {
        this.certificationAuthorityURI = certificationAuthorityURI;
        this.certificateStorage = certificateStorage;
        this.trustAllCertificate = trustAllCertificate;
        this.debugHttpRequests = debugHttpRequests;
    }

    @SuppressWarnings("serial")
    protected String getAuthorizationRequest(final KeyPair userKey, final String nextNonce, final String domain) {
        return Jwts.builder().setHeaderParam(NONCE_KEY, nextNonce)
                .setHeaderParam(JwsHeader.JSON_WEB_KEY, JWKUtils.getWebKey(userKey.getPublic()))
                .setClaims(new TreeMap<String, Object>() {
                    {
                        put(RESOURCE_KEY, RESOURCE_NEW_AUTHZ);
                        put(IDENTIFIER_KEY, new TreeMap<String, Object>() {
                            {
                                put(IDENTIFIER_TYPE_KEY, IDENTIFIER_TYPE_DNS);
                                put(IDENTIFIER_VALUE_KEY, domain);
                            }
                        });
                    }
                }).signWith(getJWSSignatureAlgorithm(), userKey.getPrivate()).compact();
    }

    public X509Certificate getCertificate(final String[] domains, final String agreement, final String[] contacts,
            AcmeChallengeListener challengeListener)
            throws IOException, InterruptedException, OperatorCreationException, StreamParsingException {
        KeyPair userKey = certificateStorage.getUserKeyPair();

        /**
         * Step 1: Get initial Nonce
         */
        String nextNonce;
        {
            Response initialNonceResponse = getRestClient().target(certificationAuthorityURI).path(RESOURCE_NEW_REG)
                    .request().head();
            nextNonce = initialNonceResponse.getHeaderString(HEADER_REPLAY_NONCE);
        }

        Thread.sleep(1000L);

        /**
         * Step 2: Register a new account with CA
         */
        String registrationURI;
        {
            Response registrationResponse = getRestClient().target(certificationAuthorityURI).path(RESOURCE_NEW_REG)
                    .request().accept(MediaType.APPLICATION_JSON)
                    .post(Entity.entity(getRegistrationRequest(userKey, nextNonce, agreement, contacts),
                            MediaType.APPLICATION_JSON));

            nextNonce = registrationResponse.getHeaderString(HEADER_REPLAY_NONCE);

            if (registrationResponse.getStatus() != Status.CREATED.getStatusCode()
                    && registrationResponse.getStatus() != Status.CONFLICT.getStatusCode()) {
                throw new AcmeException("Registration failed.", registrationResponse);
            }

            registrationURI = registrationResponse.getHeaderString(HttpHeaders.LOCATION);
        }

        Thread.sleep(1000L);

        for (String domain : domains) {
            /**
             * Step 3: Ask CA a challenge for our domain
             */
            String challengeURI = null;
            String challengeToken = null;
            {
                Response authorizationResponse = getRestClient().target(certificationAuthorityURI)
                        .path(RESOURCE_NEW_AUTHZ).request().accept(MediaType.APPLICATION_JSON).post(Entity.entity(
                                getAuthorizationRequest(userKey, nextNonce, domain), MediaType.APPLICATION_JSON));

                nextNonce = authorizationResponse.getHeaderString(HEADER_REPLAY_NONCE);

                if (authorizationResponse.getStatus() == Status.FORBIDDEN.getStatusCode()) {
                    if (agreement != null) {
                        /**
                         * Step 3b: sign new agreement
                         */
                        Response updateRegistrationResponse = getRestClient().target(registrationURI).request()
                                .accept(MediaType.APPLICATION_JSON)
                                .post(Entity.entity(
                                        getUpdateRegistrationRequest(userKey, nextNonce, agreement, contacts),
                                        MediaType.APPLICATION_JSON));

                        nextNonce = updateRegistrationResponse.getHeaderString(HEADER_REPLAY_NONCE);

                        if (updateRegistrationResponse.getStatus() != Status.ACCEPTED.getStatusCode()) {
                            throw new AcmeException("Registration failed.", updateRegistrationResponse);
                        }

                        /**
                         * Step 3c: Ask CA a challenge for our domain after agreement update
                         */
                        authorizationResponse = getRestClient().target(certificationAuthorityURI)
                                .path(RESOURCE_NEW_AUTHZ).request().accept(MediaType.APPLICATION_JSON)
                                .post(Entity.entity(getAuthorizationRequest(userKey, nextNonce, domain),
                                        MediaType.APPLICATION_JSON));

                        nextNonce = authorizationResponse.getHeaderString(HEADER_REPLAY_NONCE);

                        if (authorizationResponse.getStatus() != Status.CREATED.getStatusCode()) {
                            throw new AcmeException("Client unautorized. May need to accept new terms.",
                                    authorizationResponse);
                        }
                    } else {
                        throw new AcmeException("Client unautorized. May need to accept new terms.",
                                authorizationResponse);
                    }
                } else if (authorizationResponse.getStatus() != Status.CREATED.getStatusCode()) {
                    throw new AcmeException("Failed to create new authorization request.", authorizationResponse);
                }

                JsonNode authorizationResponseJson = new ObjectMapper()
                        .readTree((InputStream) authorizationResponse.getEntity());

                for (JsonNode challange : authorizationResponseJson.get(CHALLENGES_KEY)) {
                    String challengeType = challange.get(CHALLENGE_TYPE_KEY).asText();
                    String token = challange.get(CHALLENGE_TOKEN_KEY).asText();
                    String uri = challange.get(CHALLENGE_URI_KEY).asText();

                    if (handleChallenge(userKey, domain, challengeListener, challengeType, token, uri)) {
                        challengeURI = uri;
                        challengeToken = token;
                        break;
                    }
                }
                if (challengeURI == null) {
                    throw new AcmeException("No challenge completed.");
                }
            }

            Thread.sleep(1000L);

            /**
             * Step 4: Ask CA to verify challenge
             */
            {
                Response answerToChallengeResponse = getRestClient().target(challengeURI).request()
                        .accept(MediaType.APPLICATION_JSON)
                        .post(Entity.entity(getHTTP01ChallengeRequest(userKey, challengeToken, nextNonce),
                                MediaType.APPLICATION_JSON));

                nextNonce = answerToChallengeResponse.getHeaderString(HEADER_REPLAY_NONCE);

                if (answerToChallengeResponse.getStatus() != Status.ACCEPTED.getStatusCode()) {
                    throw new AcmeException("Failed to post challenge.", answerToChallengeResponse);
                }
            }

            Thread.sleep(1000L);

            /**
             * Step 5: Waiting for challenge verification
             */
            {
                int validateChallengeRetryCount = 20;
                while (--validateChallengeRetryCount > 0) {
                    Thread.sleep(5000L);

                    Response validateChallengeResponse = getRestClient().target(challengeURI).request()
                            .accept(MediaType.APPLICATION_JSON).get();
                    if (validateChallengeResponse.getStatus() == Status.ACCEPTED.getStatusCode()) {
                        JsonNode validateChallengeJson = new ObjectMapper()
                                .readTree((InputStream) validateChallengeResponse.getEntity());
                        String status = validateChallengeJson.get(CHALLENGE_STATUS_KEY).asText();
                        if (status.equals(CHALLENGE_STATUS_VALID)) {
                            validateChallengeRetryCount = -1;
                        } else if (!status.equals(CHALLENGE_STATUS_PENDING)) {
                            challengeListener.challengeFailed(domain);
                            throw new AcmeException(
                                    "Failed verify challenge. Domain: " + domain + " Status: " + status + " Error: "
                                            + validateChallengeJson.get("error").toString(),
                                    validateChallengeResponse);
                        }
                    } else {
                        challengeListener.challengeFailed(domain);
                        throw new AcmeException(
                                "Failed verify challenge. Domain: " + domain + " Status: Challenge not accepted",
                                validateChallengeResponse);
                    }
                }
                if (validateChallengeRetryCount == 0) {
                    challengeListener.challengeFailed(domain);
                    throw new AcmeException("Failed verify challenge. Timeout.");
                }

                challengeListener.challengeCompleted(domain);
            }

            Thread.sleep(1000L);
        }

        /**
         * Step 6: Generate CSR
         */
        KeyPair domainKey = certificateStorage.getDomainKeyPair(domains);
        final PKCS10CertificationRequest csr = X509Utils.generateCSR(domains, domainKey);
        certificateStorage.saveCSR(domains, csr);

        Thread.sleep(1000L);

        /**
         * Step 7: Ask for new certificate
         */
        String certificateURL;
        {
            Response newCertificateResponse = getRestClient().target(certificationAuthorityURI)
                    .path(RESOURCE_NEW_CERT).request().accept(MediaType.APPLICATION_JSON).post(Entity
                            .entity(getNewCertificateRequest(userKey, nextNonce, csr), MediaType.APPLICATION_JSON));

            if (newCertificateResponse.getStatus() == Status.CREATED.getStatusCode()) {
                certificateURL = newCertificateResponse.getHeaderString(HttpHeaders.LOCATION);
                if (newCertificateResponse.getLength() > 0) {
                    return extractCertificate(domains, (InputStream) newCertificateResponse.getEntity());
                }
            } else if (newCertificateResponse.getStatus() == 429) {
                throw new AcmeException("You are rate limited.", newCertificateResponse);
            } else {
                throw new AcmeException("Failed to download certificate.", newCertificateResponse);
            }
        }

        Thread.sleep(1000L);

        /**
         * Step 8: Fetch new certificate (if not already returned)
         */
        {
            int downloadRetryCount = 20;
            while (downloadRetryCount-- > 0) {
                Thread.sleep(5000L);
                Response certificateResponse = getRestClient().target(certificateURL).request().get();
                if (certificateResponse.getStatus() == Status.CREATED.getStatusCode()) {
                    if (certificateResponse.getLength() > 0) {
                        return extractCertificate(domains, (InputStream) certificateResponse.getEntity());
                    }
                } else {
                    throw new AcmeException("Failed to download certificate.", certificateResponse);
                }
            }

            throw new AcmeException("Failed to download certificate. Timeout.");
        }
    }

    private X509Certificate extractCertificate(final String[] domains, InputStream inputStream)
            throws StreamParsingException {
        X509CertParser certParser = new X509CertParser();
        certParser.engineInit(inputStream);
        X509Certificate certificate = (X509Certificate) certParser.engineRead();
        certificateStorage.saveCertificate(domains, certificate);
        return certificate;
    }

    protected SignatureAlgorithm getJWSSignatureAlgorithm() {
        return SignatureAlgorithm.RS256;
    }

    @SuppressWarnings("serial")
    protected String getNewCertificateRequest(final KeyPair userKey, final String nonce,
            final PKCS10CertificationRequest csr) throws IOException {
        return Jwts.builder().setHeaderParam(NONCE_KEY, nonce)
                .setHeaderParam(JwsHeader.JSON_WEB_KEY, JWKUtils.getWebKey(userKey.getPublic()))
                .setClaims(new TreeMap<String, Object>() {
                    {
                        put(RESOURCE_KEY, RESOURCE_NEW_CERT);
                        put(CSR_KEY, TextCodec.BASE64URL.encode(csr.getEncoded()));
                    }
                }).signWith(getJWSSignatureAlgorithm(), userKey.getPrivate()).compact();
    }

    @SuppressWarnings("serial")
    protected String getRegistrationRequest(final KeyPair userKey, final String nonce, final String agreement,
            final String[] contacts) {
        return Jwts.builder().setHeaderParam(NONCE_KEY, nonce)
                .setHeaderParam(JwsHeader.JSON_WEB_KEY, JWKUtils.getWebKey(userKey.getPublic()))
                .setClaims(new TreeMap<String, Object>() {
                    {
                        put(RESOURCE_KEY, RESOURCE_NEW_REG);
                        if (contacts != null && contacts.length > 0) {
                            put(CONTACT_KEY, contacts);
                        }
                        if (agreement != null) {
                            put(AGREEMENT_KEY, agreement);
                        }
                    }
                }).signWith(getJWSSignatureAlgorithm(), userKey.getPrivate()).compact();
    }

    protected Client getRestClient() {
        try {
            Client client = ClientBuilder.newBuilder()
                    .sslContext(
                            (trustAllCertificate) ? getTrustAllCertificateSSLContext() : SSLContext.getDefault())
                    .build();

            if (debugHttpRequests) {
                try {
                    Class<?> clazz = Class.forName("org.glassfish.jersey.filter.LoggingFilter");
                    Constructor<?> contructor = clazz.getConstructor(Logger.class, boolean.class);
                    client.register(contructor.newInstance(Logger.getLogger("it.zero11.acme"), true));
                } catch (Exception e) {

                }
            }

            return client;
        } catch (NoSuchAlgorithmException | KeyManagementException e) {
            throw new AcmeException(e);
        }
    }

    protected String getHTTP01ChallengeContent(final KeyPair userKey, final String token) {
        return token + "." + JWKUtils.getWebKeyThumbprintSHA256(userKey.getPublic());
    }

    @SuppressWarnings("serial")
    protected String getHTTP01ChallengeRequest(final KeyPair userKey, final String token, final String nonce) {
        return Jwts.builder().setHeaderParam(NONCE_KEY, nonce)
                .setHeaderParam(JwsHeader.JSON_WEB_KEY, JWKUtils.getWebKey(userKey.getPublic()))
                .setClaims(new TreeMap<String, Object>() {
                    {
                        put(RESOURCE_KEY, RESOURCE_CHALLENGE);
                        put(CHALLENGE_TYPE_KEY, CHALLENGE_TYPE_HTTP_01);
                        put(CHALLENGE_TLS_KEY, true);
                        put(CHALLENGE_KEY_AUTHORIZATION_KEY, getHTTP01ChallengeContent(userKey, token));
                        put(CHALLENGE_TOKEN_KEY, token);
                    }
                }).signWith(getJWSSignatureAlgorithm(), userKey.getPrivate()).compact();
    }

    @SuppressWarnings("serial")
    protected String getUpdateRegistrationRequest(final KeyPair userKey, final String nonce, final String agreement,
            final String[] contacts) {
        return Jwts.builder().setHeaderParam(NONCE_KEY, nonce)
                .setHeaderParam(JwsHeader.JSON_WEB_KEY, JWKUtils.getWebKey(userKey.getPublic()))
                .setClaims(new TreeMap<String, Object>() {
                    {
                        put(RESOURCE_KEY, RESOURCE_UPDATE_REGISTRATION);
                        if (contacts != null && contacts.length > 0) {
                            put(CONTACT_KEY, contacts);
                        }
                        put(AGREEMENT_KEY, agreement);
                    }
                }).signWith(getJWSSignatureAlgorithm(), userKey.getPrivate()).compact();
    }

    private boolean handleChallenge(KeyPair userKey, String domain, AcmeChallengeListener challengeListener,
            String challengeType, String token, String challengeURI) {
        switch (challengeType) {
        case CHALLENGE_TYPE_HTTP_01:
            return challengeListener.challengeHTTP01(domain, token, challengeURI,
                    getHTTP01ChallengeContent(userKey, token));
        default:
            return false;
        }
    }
}