/** * 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; } } }