From 7e07a0e2e4e19909f94c291bb98746e3a2471791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Wed, 9 Dec 2015 01:24:03 +0100 Subject: [PATCH] Project start --- .project | 6 - README.md | 39 ++- acme4j-client/.project | 23 ++ acme4j-client/pom.xml | 43 +++ acme4j-client/src/main/java/.gitignore | 0 .../java/org/shredzone/acme4j/Account.java | 46 +++ .../java/org/shredzone/acme4j/AcmeClient.java | 103 ++++++ .../shredzone/acme4j/AcmeClientFactory.java | 67 ++++ .../org/shredzone/acme4j/Authorization.java | 153 +++++++++ .../org/shredzone/acme4j/Registration.java | 68 ++++ .../shredzone/acme4j/challenge/Challenge.java | 83 +++++ .../acme4j/challenge/DnsChallenge.java | 69 ++++ .../acme4j/challenge/GenericChallenge.java | 139 ++++++++ .../acme4j/challenge/HttpChallenge.java | 73 ++++ .../challenge/ProofOfPossessionChallenge.java | 49 +++ .../acme4j/challenge/TlsSniChallenge.java | 46 +++ .../acme4j/connector/Connection.java | 313 ++++++++++++++++++ .../shredzone/acme4j/connector/Resource.java | 55 +++ .../shredzone/acme4j/connector/Session.java | 39 +++ .../acme4j/exception/AcmeException.java | 36 ++ .../acme4j/exception/AcmeServerException.java | 66 ++++ .../acme4j/impl/AbstractAcmeClient.java | 209 ++++++++++++ .../acme4j/impl/GenericAcmeClient.java | 73 ++++ .../provider/AbstractAcmeClientProvider.java | 98 ++++++ .../acme4j/provider/AcmeClientProvider.java | 82 +++++ .../provider/GenericAcmeClientProvider.java | 51 +++ .../shredzone/acme4j/util/ClaimBuilder.java | 159 +++++++++ acme4j-client/src/main/resources/.gitignore | 0 ...redzone.acme4j.provider.AcmeClientProvider | 2 + acme4j-client/src/site/apt/index.apt | 1 + acme4j-client/src/site/resources/css/site.css | 6 + acme4j-client/src/site/site.xml | 38 +++ acme4j-client/src/test/java/.gitignore | 0 acme4j-client/src/test/resources/.gitignore | 0 acme4j-example/.gitignore | 3 + acme4j-example/.project | 23 ++ acme4j-example/pom.xml | 55 +++ acme4j-example/src/main/java/.gitignore | 0 .../java/org/shredzone/acme4j/ClientTest.java | 206 ++++++++++++ acme4j-example/src/main/resources/.gitignore | 0 .../main/resources/simplelogger.properties | 2 + acme4j-example/src/site/apt/index.apt | 1 + .../src/site/resources/css/site.css | 6 + acme4j-example/src/site/site.xml | 38 +++ acme4j-example/src/test/java/.gitignore | 0 acme4j-example/src/test/resources/.gitignore | 0 acme4j-letsencrypt/.project | 23 ++ acme4j-letsencrypt/pom.xml | 38 +++ acme4j-letsencrypt/src/main/java/.gitignore | 0 .../LetsEncryptAcmeClientProvider.java | 117 +++++++ .../src/main/resources/.gitignore | 0 ...redzone.acme4j.provider.AcmeClientProvider | 2 + .../shredzone/acme4j/letsencrypt.truststore | Bin 0 -> 1878 bytes acme4j-letsencrypt/src/site/apt/index.apt | 1 + .../src/site/resources/css/site.css | 6 + acme4j-letsencrypt/src/site/site.xml | 38 +++ acme4j-letsencrypt/src/test/java/.gitignore | 0 .../src/test/resources/.gitignore | 0 acme4j-utils/.project | 23 ++ acme4j-utils/pom.xml | 54 +++ acme4j-utils/src/main/java/.gitignore | 0 .../org/shredzone/acme4j/util/CSRBuilder.java | 212 ++++++++++++ .../acme4j/util/CertificateUtils.java | 49 +++ .../shredzone/acme4j/util/KeyPairUtils.java | 89 +++++ acme4j-utils/src/main/resources/.gitignore | 0 acme4j-utils/src/site/apt/index.apt | 1 + acme4j-utils/src/site/resources/css/site.css | 6 + acme4j-utils/src/site/site.xml | 38 +++ acme4j-utils/src/test/java/.gitignore | 0 acme4j-utils/src/test/resources/.gitignore | 0 pom.xml | 168 ++++++++++ src/site/apt/index.apt | 1 + src/site/resources/css/site.css | 6 + src/site/site.xml | 37 +++ 74 files changed, 3468 insertions(+), 10 deletions(-) create mode 100644 acme4j-client/.project create mode 100644 acme4j-client/pom.xml create mode 100644 acme4j-client/src/main/java/.gitignore create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/Account.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClient.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClientFactory.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/connector/Session.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeException.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeServerException.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/impl/AbstractAcmeClient.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/impl/GenericAcmeClient.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeClientProvider.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/provider/GenericAcmeClientProvider.java create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/util/ClaimBuilder.java create mode 100644 acme4j-client/src/main/resources/.gitignore create mode 100644 acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider create mode 100644 acme4j-client/src/site/apt/index.apt create mode 100644 acme4j-client/src/site/resources/css/site.css create mode 100644 acme4j-client/src/site/site.xml create mode 100644 acme4j-client/src/test/java/.gitignore create mode 100644 acme4j-client/src/test/resources/.gitignore create mode 100644 acme4j-example/.gitignore create mode 100644 acme4j-example/.project create mode 100644 acme4j-example/pom.xml create mode 100644 acme4j-example/src/main/java/.gitignore create mode 100644 acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java create mode 100644 acme4j-example/src/main/resources/.gitignore create mode 100644 acme4j-example/src/main/resources/simplelogger.properties create mode 100644 acme4j-example/src/site/apt/index.apt create mode 100644 acme4j-example/src/site/resources/css/site.css create mode 100644 acme4j-example/src/site/site.xml create mode 100644 acme4j-example/src/test/java/.gitignore create mode 100644 acme4j-example/src/test/resources/.gitignore create mode 100644 acme4j-letsencrypt/.project create mode 100644 acme4j-letsencrypt/pom.xml create mode 100644 acme4j-letsencrypt/src/main/java/.gitignore create mode 100644 acme4j-letsencrypt/src/main/java/org/shredzone/acme4j/provider/LetsEncryptAcmeClientProvider.java create mode 100644 acme4j-letsencrypt/src/main/resources/.gitignore create mode 100644 acme4j-letsencrypt/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider create mode 100644 acme4j-letsencrypt/src/main/resources/org/shredzone/acme4j/letsencrypt.truststore create mode 100644 acme4j-letsencrypt/src/site/apt/index.apt create mode 100644 acme4j-letsencrypt/src/site/resources/css/site.css create mode 100644 acme4j-letsencrypt/src/site/site.xml create mode 100644 acme4j-letsencrypt/src/test/java/.gitignore create mode 100644 acme4j-letsencrypt/src/test/resources/.gitignore create mode 100644 acme4j-utils/.project create mode 100644 acme4j-utils/pom.xml create mode 100644 acme4j-utils/src/main/java/.gitignore create mode 100644 acme4j-utils/src/main/java/org/shredzone/acme4j/util/CSRBuilder.java create mode 100644 acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java create mode 100644 acme4j-utils/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java create mode 100644 acme4j-utils/src/main/resources/.gitignore create mode 100644 acme4j-utils/src/site/apt/index.apt create mode 100644 acme4j-utils/src/site/resources/css/site.css create mode 100644 acme4j-utils/src/site/site.xml create mode 100644 acme4j-utils/src/test/java/.gitignore create mode 100644 acme4j-utils/src/test/resources/.gitignore create mode 100644 pom.xml create mode 100644 src/site/apt/index.apt create mode 100644 src/site/resources/css/site.css create mode 100644 src/site/site.xml diff --git a/.project b/.project index cd960b79..e5693afd 100644 --- a/.project +++ b/.project @@ -10,11 +10,6 @@ - - org.eclipse.wst.common.project.facet.core.builder - - - org.eclipse.m2e.core.maven2Builder @@ -24,6 +19,5 @@ org.eclipse.jdt.core.javanature org.eclipse.m2e.core.maven2Nature - org.eclipse.wst.common.project.facet.core.nature diff --git a/README.md b/README.md index 548b4914..cecd3d19 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # ACME Java Client ![build status](http://jenkins.shredzone.net/buildStatus/icon?job=acme4j) -*SOURCE IS COMING SOON!* - This is a Java client for the [ACME](https://tools.ietf.org/html/draft-ietf-acme-acme-01) protocol. ACME is a protocol that a certificate authority (CA) and an applicant can use to automate the process of verification and certificate issuance. @@ -10,10 +8,43 @@ This Java client helps connecting to an ACME server, and performing all necessar It is an independent open source implementation that is not affiliated with or endorsed by _Let's Encrypt_. The source code can be found at [GitHub](https://github.com/shred/acme4j) and is distributed under the terms of [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0). +Alpha Release! +-------------- + +Please note that even though _acme4j_ is already usable, it is currently in an early alpha state. This means that: + +* _acme4j_ is not feature complete yet (see the "Missing" section below). +* The API is not stable. It may change in a manner not compatible to previous versions and without prior notice. +* _acme4j_ is not thoroughly tested yet, and may still have major bugs. + Features -------- * Easy to use Java API * Requires JRE 7 or higher -* Built with maven (package will be available shortly at Maven Central) -* Small, only requires [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) and [slf4j](http://www.slf4j.org/) as dependencies. [Bouncy Castle](https://www.bouncycastle.org/java.html) is recommended for some features, but is not required. +* Built with maven (package will be made available at Maven Central as soon as beta state is reached) +* Small, only requires [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) and [slf4j](http://www.slf4j.org/) as dependencies. [Bouncy Castle](https://www.bouncycastle.org/java.html) is recommended, but not required. + +How to Use +---------- + +_acme4j_ consists of a few modules: + +* _acme4j-client_: This is the main module. It contains the ACME client and everything needed for communication with an ACME server. +* _acme4j-letsencrypt_: A _Let's Encrypt_ service. Just add it as dependency, it will neatly plug into the client. +* _acme4j-utils_: Some utility classes that may be helpful for creating key pairs, certificates, and certificate signing requests. Requires [Bouncy Castle](https://www.bouncycastle.org/java.html). +* _acme4j-example_: An example tool that performs all steps for registering a new account at _Let's Encrypt_ and getting a certificate for a set of domain names. This is a good starting point to find out how _acme4j_ is used. + +Missing +------- + +The following features are planned to be completed for the first beta release, but are still missing: + +* Support of account recovery and certificate revocation. +* `proofOfPossession-01` and `tls-sni-01` challenge support. +* Extensive unit tests. +* Better error handling. +* Some hardening (like plausibility checks). +* Full documentation. + +_acme4j_ is open source software. Feel free to send in pull requests! diff --git a/acme4j-client/.project b/acme4j-client/.project new file mode 100644 index 00000000..27ef9730 --- /dev/null +++ b/acme4j-client/.project @@ -0,0 +1,23 @@ + + + acme4j-client + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/acme4j-client/pom.xml b/acme4j-client/pom.xml new file mode 100644 index 00000000..7b9207b2 --- /dev/null +++ b/acme4j-client/pom.xml @@ -0,0 +1,43 @@ + + + + 4.0.0 + + + org.shredzone.acme4j + acme4j + 0.1-SNAPSHOT + + + acme4j-client + + acme4j client + ACME client for Java + + + + org.bitbucket.b_c + jose4j + ${jose4j.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + diff --git a/acme4j-client/src/main/java/.gitignore b/acme4j-client/src/main/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java new file mode 100644 index 00000000..1c97b275 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java @@ -0,0 +1,46 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.security.KeyPair; + +/** + * Represents an account at the ACME server. + *

+ * An account is identified by its {@link KeyPair}. + * + * @author Richard "Shred" Körber + */ +public class Account { + + private final KeyPair keyPair; + + /** + * Creates a new {@link Account} instance. + * + * @param keyPair + * {@link KeyPair} that identifies the account. + */ + public Account(KeyPair keyPair) { + this.keyPair = keyPair; + } + + /** + * The {@link KeyPair} that belongs to this account. + */ + public KeyPair getKeyPair() { + return keyPair; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClient.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClient.java new file mode 100644 index 00000000..e88c760e --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClient.java @@ -0,0 +1,103 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.net.URI; +import java.security.cert.X509Certificate; + +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.exception.AcmeException; + +/** + * An {@link AcmeClient} is used for communication with an ACME server. + *

+ * Use {@link AcmeClientFactory} to generate instances. + * + * @author Richard "Shred" Körber + */ +public interface AcmeClient { + + /** + * Registers a new account. + * + * @param account + * {@link Account} to register + * @param registration + * {@link Registration} containing registration data + */ + void newRegistration(Account account, Registration registration) throws AcmeException; + + /** + * Updates an existing account. + * + * @param account + * {@link Account} that is registered + * @param registration + * {@link Registration} containing updated registration data. Set the + * account location via {@link Registration#setLocation(URI)}! + */ + void updateRegistration(Account account, Registration registration) throws AcmeException; + + /** + * Creates a new {@link Authorization} for a domain. + * + * @param account + * {@link Account} the authorization is related to + * @param auth + * {@link Authorization} containing the domain name + */ + void newAuthorization(Account account, Authorization auth) throws AcmeException; + + /** + * Triggers a {@link Challenge}. The ACME server is requested to validate the + * response. Note that the validation is performed asynchronously. + * + * @param account + * {@link Account} to be used for conversation + * @param challenge + * {@link Challenge} to trigger + */ + void triggerChallenge(Account account, Challenge challenge) throws AcmeException; + + /** + * Updates the {@link Challenge} instance. It contains the current state. + * + * @param account + * {@link Account} to be used for conversation + * @param challenge + * {@link Challenge} to update + */ + void updateChallenge(Account account, Challenge challenge) throws AcmeException; + + /** + * Request a certificate. + * + * @param account + * {@link Account} to be used for conversation + * @param csr + * PKCS#10 Certificate Signing Request to be sent to the server + * @return {@link URI} the certificate can be downloaded from + */ + URI requestCertificate(Account account, byte[] csr) throws AcmeException; + + /** + * Downloads a certificate. + * + * @param certUri + * Certificate {@link URI} + * @return Downloaded {@link X509Certificate} + */ + X509Certificate downloadCertificate(URI certUri) throws AcmeException; + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClientFactory.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClientFactory.java new file mode 100644 index 00000000..1e8e969b --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeClientFactory.java @@ -0,0 +1,67 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; + +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.provider.AcmeClientProvider; + +/** + * Generates {@link AcmeClient} instances. + *

+ * An {@link AcmeClient} is generated by an {@link AcmeClientProvider}. There are generic + * providers and providers tailor-made for specific ACME servers. All providers are + * managed via Java's {@link ServiceLoader} API. + * + * @author Richard "Shred" Körber + */ +public final class AcmeClientFactory { + + private AcmeClientFactory() { + // utility class without constructor + } + + /** + * Connects to an ACME server and provides an {@link AcmeClient} for communication. + * + * @param serverUri + * URI of the ACME server. This can either be a http/https URI to the + * server's directory service, or a special acme URI for specific + * implementations. + * @return {@link AcmeClient} for communication with the server + */ + public static AcmeClient connect(String serverUri) throws AcmeException { + List candidates = new ArrayList<>(); + for (AcmeClientProvider acp : ServiceLoader.load(AcmeClientProvider.class)) { + if (acp.accepts(serverUri)) { + candidates.add(acp); + } + } + + if (candidates.isEmpty()) { + throw new AcmeException("No ACME provider found for " + serverUri); + } else if (candidates.size() > 1) { + throw new IllegalArgumentException("There are " + candidates.size() + " " + + AcmeClientProvider.class.getSimpleName() + " accepting " + serverUri + + ". Please check your classpath."); + } else { + AcmeClientProvider provider = candidates.get(0); + return provider.connect(serverUri); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java new file mode 100644 index 00000000..87373b0f --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -0,0 +1,153 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import org.shredzone.acme4j.challenge.Challenge; + +/** + * Represents an authorization request at the ACME server. + * + * @author Richard "Shred" Körber + */ +public class Authorization { + + private String domain; + private String status; + private Date expires; + private List challenges; + private List> combinations; + + /** + * Gets the domain name to be authorized. + */ + public String getDomain() { + return domain; + } + + /** + * Sets the domain name to authorize. + */ + public void setDomain(String domain) { + this.domain = domain; + } + + /** + * Gets the authorization status. + */ + public String getStatus() { + return status; + } + + /** + * Sets the authorization status. + */ + public void setStatus(String status) { + this.status = status; + } + + /** + * Gets the expiry date of the authorization, if set by the server. + */ + public Date getExpires() { + return expires; + } + + /** + * Sets the expiry date of the authorization. + */ + public void setExpires(Date expires) { + this.expires = expires; + } + + /** + * Gets a list of all challenges available by the server. + */ + public List getChallenges() { + return challenges; + } + + /** + * Sets a list of all challenges available by the server. + */ + public void setChallenges(List challenges) { + this.challenges = challenges; + } + + /** + * Gets all combinations of challenges supported by the server. + */ + public List> getCombinations() { + return combinations; + } + + /** + * Sets all combinations of challenges supported by the server. + */ + public void setCombinations(List> combinations) { + this.combinations = combinations; + } + + /** + * Finds a single {@link Challenge} of the given type. Responding to this + * {@link Challenge} is sufficient for authorization. This is a convenience call to + * {@link #findCombination(String...)}. + * + * @param type + * Challenge name (e.g. "http-01") + * @return {@link Challenge} matching that name, or {@code null} if there is no such + * challenge, or the challenge alone is not sufficient for authorization. + * @throws ClassCastException + * if the type does not match the expected Challenge class type + */ + @SuppressWarnings("unchecked") + public T findChallenge(String type) { + Collection result = findCombination(type); + return (result != null ? (T) result.iterator().next() : null); + } + + /** + * Finds a combination of {@link Challenge} types that the client supports. The client + * has to respond to all of the {@link Challenge}s returned. + * + * @param types + * Challenge name or names (e.g. "http-01"), in no particular order. + * @return Matching {@link Challenge} combination, or {@code null} if the ACME server + * does not support this challenge combination. The challenges are returned + * in no particular order. + */ + public Collection findCombination(String... types) { + Collection reference = Arrays.asList(types); + + for (List combination : combinations) { + Collection combinationTypes = new ArrayList<>(); + for (Challenge c : combination) { + combinationTypes.add(c.getType()); + } + + if (reference.size() == combinationTypes.size() + && reference.containsAll(combinationTypes)) { + return combination; + } + } + + return null; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java new file mode 100644 index 00000000..0a63c1ce --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java @@ -0,0 +1,68 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a registration at the ACME server. + * + * @author Richard "Shred" Körber + */ +public class Registration { + + private String agreementUrl; + private List contacts = new ArrayList<>(); + private URI location; + + /** + * Returns the URL of the agreement document the user is required to accept. + */ + public String getAgreementUrl() { + return agreementUrl; + } + + /** + * Sets the URL of the agreement document the user is required to accept. + */ + public void setAgreementUrl(String agreementUrl) { + this.agreementUrl = agreementUrl; + } + + /** + * List of contact email addresses. + */ + public List getContacts() { + return contacts; + } + + /** + * Location URI of the registration at the server. Returned from the server after + * successfully creating or updating a registration. + */ + public URI getLocation() { + return location; + } + + /** + * Location URI of the registration at the server. Must be set when updating the + * registration. + */ + public void setLocation(URI location) { + this.location = location; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java new file mode 100644 index 00000000..a5f4006a --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java @@ -0,0 +1,83 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import java.net.URI; +import java.util.Date; +import java.util.Map; + +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * A challenge. + * + * @author Richard "Shred" Körber + */ +public interface Challenge { + + /** + * Challenge status enumeration. + */ + public enum Status { + PENDING, PROCESSING, VALID, INVALID, REVOKED, UNKNOWN; + } + + /** + * Returns the challenge type by name (e.g. "http-01"). + */ + String getType(); + + /** + * Returns the {@link URI} of the challenge. + */ + URI getUri(); + + /** + * Returns the current status of the challenge. + */ + Status getStatus(); + + /** + * Returns the validation date, if returned by the server. + */ + Date getValidated(); + + /** + * Authorizes a {@link Challenge} by signing it with an {@link Account}. This is + * required before triggering the challenge. + * + * @param account + * {@link Account} to sign the challenge with + */ + void authorize(Account account); + + /** + * Sets the challenge state by reading the given JSON map. + * + * @param map + * JSON map containing the challenge data + */ + void unmarshall(Map map); + + /** + * Copies the current challenge state to the claim builder, as preparation for + * triggering it. + * + * @param cb + * {@link ClaimBuilder} to copy the challenge state to + */ + void marshall(ClaimBuilder cb); + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java new file mode 100644 index 00000000..dee9d564 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/DnsChallenge.java @@ -0,0 +1,69 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import org.jose4j.base64url.Base64Url; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * Implements the {@code dns-01} challenge. + * + * @author Richard "Shred" Körber + */ +public class DnsChallenge extends GenericChallenge { + + /** + * Challenge type name. + */ + public static final String TYPE = "dns-01"; + + private String authorization; + + /** + * Returns the token to be used for this challenge. + */ + public String getToken() { + return get(KEY_TOKEN); + } + + /** + * Sets the token to be used. + */ + public void setToken(String token) { + put(KEY_TOKEN, token); + } + + /** + * Returns the authorization string to be used for the response. + */ + public String getAuthorization() { + return authorization; + } + + @Override + public void authorize(Account account) { + super.authorize(account); + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); + } + + @Override + public void marshall(ClaimBuilder cb) { + if (authorization == null) { + throw new IllegalStateException("Challenge has not been authorized yet."); + } + cb.put(KEY_KEY_AUTHORIZSATION, authorization); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java new file mode 100644 index 00000000..fa3e394f --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/GenericChallenge.java @@ -0,0 +1,139 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jwk.JsonWebKey.OutputControlLevel; +import org.jose4j.lang.JoseException; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * A generic implementation of {@link Challenge}. It can be used as a base class for + * actual challenge implemenation, but it is also used if the ACME server offers a + * proprietary challenge that is unknown to acme4j. + * + * @author Richard "Shred" Körber + */ +public class GenericChallenge implements Challenge { + + protected static final String KEY_TYPE = "type"; + protected static final String KEY_STATUS = "status"; + protected static final String KEY_URI = "uri"; + protected static final String KEY_VALIDATED = "validated"; + protected static final String KEY_TOKEN = "token"; + protected static final String KEY_KEY_AUTHORIZSATION = "keyAuthorization"; + + private final Map data = new HashMap<>(); + + @Override + public String getType() { + return get(KEY_TYPE); + } + + @Override + public Status getStatus() { + String status = get(KEY_STATUS); + return (status != null ? Status.valueOf(status.toUpperCase()) : Status.PENDING); + } + + @Override + public URI getUri() { + try { + return new URI(get(KEY_URI).toString()); + } catch (URISyntaxException ex) { + throw new IllegalStateException("Invalid URI", ex); + } + } + + @Override + public Date getValidated() { + return get(KEY_VALIDATED); + } + + @Override + public void authorize(Account account) { + // Standard implementation does nothing... + } + + @Override + public void unmarshall(Map map) { + data.clear(); + data.putAll(map); + } + + @Override + public void marshall(ClaimBuilder cb) { + cb.putAll(data); + } + + /** + * Gets a value from the challenge state. + * + * @param key + * Key + * @return Value, or {@code null} if not set + */ + @SuppressWarnings("unchecked") + protected T get(String key) { + return (T) data.get(key); + } + + /** + * Puts a value to the challenge state. + * + * @param key + * Key + * @param value + * Value, may be {@code null} + */ + protected void put(String key, Object value) { + data.put(key, value); + } + + /** + * Computes a JWK Thumbprint. It is frequently used in responses. + * + * @param key + * {@link Key} to create a thumbprint of + * @return Thumbprint, SHA-256 hashed + * @see RFC 7638 + */ + public static byte[] jwkThumbprint(Key key) { + try { + final JsonWebKey jwk = JsonWebKey.Factory.newJwk(key); + + // We need to use ClaimBuilder to bring the keys in lexicographical order. + ClaimBuilder cb = new ClaimBuilder(); + cb.putAll(jwk.toParams(OutputControlLevel.PUBLIC_ONLY)); + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(cb.toString().getBytes("UTF-8")); + return md.digest(); + } catch (JoseException | NoSuchAlgorithmException | UnsupportedEncodingException ex) { + throw new IllegalArgumentException("Cannot compute key thumbprint", ex); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java new file mode 100644 index 00000000..21255808 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/HttpChallenge.java @@ -0,0 +1,73 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import org.jose4j.base64url.Base64Url; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * Implements the {@code http-01} challenge. + * + * @author Richard "Shred" Körber + */ +public class HttpChallenge extends GenericChallenge { + + /** + * Challenge type name. + */ + public static final String TYPE = "http-01"; + + private String authorization; + + /** + * Returns the token to be used for this challenge. + */ + public String getToken() { + return get(KEY_TOKEN); + } + + /** + * Sets the token to be used. + */ + public void setToken(String token) { + put(KEY_TOKEN, token); + } + + /** + * Returns the authorization string to be used for the response. + *

+ * NOTE: The response file must only contain the returned String (UTF-8 + * or ASCII encoded). There must not be any other leading or trailing characters + * (like white-spaces or line breaks). Otherwise the challenge will fail. + */ + public String getAuthorization() { + return authorization; + } + + @Override + public void authorize(Account account) { + super.authorize(account); + authorization = getToken() + '.' + Base64Url.encode(jwkThumbprint(account.getKeyPair().getPublic())); + } + + @Override + public void marshall(ClaimBuilder cb) { + if (authorization == null) { + throw new IllegalStateException("Challenge has not been authorized yet."); + } + cb.put(KEY_KEY_AUTHORIZSATION, authorization); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java new file mode 100644 index 00000000..f3c5e33a --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/ProofOfPossessionChallenge.java @@ -0,0 +1,49 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +import java.security.Key; + +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * Implements the {@code proofOfPossession-01} challenge. + *

+ * TODO: Currently this challenge is not implemented. + * + * @author Richard "Shred" Körber + */ +public class ProofOfPossessionChallenge extends GenericChallenge { + + /** + * Challenge type name. + */ + public static final String TYPE = "proofOfPossession-01"; + + private Key accountKey; + + @Override + public void authorize(Account account) { + super.authorize(account); + accountKey = account.getKeyPair().getPublic(); + } + + @Override + public void marshall(ClaimBuilder cb) { + super.marshall(cb); + cb.putKey("accountKey", accountKey); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java new file mode 100644 index 00000000..e2205ac3 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/TlsSniChallenge.java @@ -0,0 +1,46 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.challenge; + +/** + * Implements the {@code tls-sni-01} challenge. + *

+ * TODO: Currently this challenge is not implemented. + * + * @author Richard "Shred" Körber + */ +public class TlsSniChallenge extends GenericChallenge { + + /** + * Challenge type name. + */ + public static final String TYPE = "tls-sni-01"; + + public String getToken() { + return get(KEY_TOKEN); + } + + public void setToken(String token) { + put(KEY_TOKEN, token); + } + + public int getN() { + return get("n"); + } + + public void setN(int n) { + put("n", n); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java new file mode 100644 index 00000000..98d93f8a --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java @@ -0,0 +1,313 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.connector; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.KeyPair; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.EnumMap; +import java.util.Map; + +import org.jose4j.base64url.Base64Url; +import org.jose4j.json.JsonUtil; +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.lang.JoseException; +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeServerException; +import org.shredzone.acme4j.provider.AcmeClientProvider; +import org.shredzone.acme4j.util.ClaimBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Connects to the ACME server and offers different methods for invoking the API. + * + * @author Richard "Shred" Körber + */ +public class Connection implements AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(Connection.class); + + private final AcmeClientProvider provider; + private HttpURLConnection conn; + + public Connection(AcmeClientProvider provider) { + this.provider = provider; + } + + @Override + public void close() { + conn = null; + } + + /** + * Forcedly starts a new {@link Session}. Usually this method is not required, as a + * session is automatically started if necessary. + * + * @param uri + * {@link URI} a HEAD request is sent to for starting the session + * @param session + * {@link Session} instance to be used for tracking + */ + public void startSession(URI uri, Session session) throws AcmeException { + try { + LOG.debug("Initial replay nonce from {}", uri); + HttpURLConnection conn = provider.openConnection(uri); + conn.setRequestMethod("HEAD"); + conn.setRequestProperty("Accept-Charset", "utf-8"); + conn.connect(); + + session.setNonce(getNonceFromHeader(conn)); + } catch (IOException ex) { + throw new AcmeException("Failed to request a nonce", ex); + } + } + + /** + * Sends a simple GET request. + * + * @param uri + * {@link URI} to send the request to. + * @return HTTP response code + */ + public int sendRequest(URI uri) throws AcmeException { + try { + LOG.debug("GET {}", uri); + + conn = provider.openConnection(uri); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept-Charset", "utf-8"); + conn.setDoOutput(false); + + conn.connect(); + + throwException(); + + return conn.getResponseCode(); + } catch (IOException ex) { + throw new AcmeException("API access failed", ex); + } + } + + /** + * Sends a signed POST request. + * + * @param uri + * {@link URI} to send the request to. + * @param claims + * {@link ClaimBuilder} containing claims. Must not be {@code null}. + * @param session + * {@link Session} instance to be used for tracking + * @param account + * {@link Account} to be used for signing the request + * @return HTTP response code + */ + public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException { + try { + KeyPair keypair = account.getKeyPair(); + + if (session.getNonce() == null) { + startSession(uri, session); + } + if (session.getNonce() == null) { + throw new AcmeException("No nonce available"); + } + + LOG.debug("POST {} with claims: {}", uri, claims); + + conn = provider.openConnection(uri); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Charset", "utf-8"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + + final JsonWebKey jwk = JsonWebKey.Factory.newJwk(keypair.getPublic()); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toString()); + jws.getHeaders().setObjectHeaderValue("nonce", Base64Url.encode(session.getNonce())); + jws.getHeaders().setJwkHeaderValue("jwk", jwk); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + jws.setKey(keypair.getPrivate()); + byte[] outputData = jws.getCompactSerialization().getBytes("utf-8"); + + conn.setFixedLengthStreamingMode(outputData.length); + conn.connect(); + + try (OutputStream out = conn.getOutputStream()) { + out.write(outputData); + } + + session.setNonce(getNonceFromHeader(conn)); + + throwException(); + + return conn.getResponseCode(); + } catch (JoseException | IOException ex) { + throw new AcmeException("Failed to send request to " + uri, ex); + } + } + + /** + * Reads a server response as JSON data. + * + * @return Map containing the parsed JSON data + */ + public Map readJsonResponse() throws AcmeException { + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + + StringBuilder sb = new StringBuilder(); + Map result = null; + + try { + InputStream in = (conn.getResponseCode() < 400 ? conn.getInputStream() : conn.getErrorStream()); + if (in != null) { + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, "utf-8"))) { + sb.append(r.readLine()); + } + + result = JsonUtil.parseJson(sb.toString()); + LOG.debug("Result JSON: {}", sb); + } + + } catch (JoseException | IOException ex) { + throw new AcmeException("Failed to parse response: " + sb, ex); + } + + return result; + } + + /** + * Reads a certificate. + * + * @return {@link X509Certificate} that was read. + */ + public X509Certificate readCertificate() throws AcmeException { + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + + try (InputStream in = conn.getInputStream()) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(in); + } catch (IOException ex) { + throw new AcmeException("Failed to read certificate", ex); + } catch (CertificateException ex) { + throw new AcmeException("Error while generating the X.509 certificate", ex); + } + } + + /** + * Reads a resource directory. + * + * @return Map of {@link Resource} and the respective {@link URI} to invoke + */ + public Map readResourceMap() throws AcmeException { + EnumMap resourceMap = new EnumMap<>(Resource.class); + + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) { + sb.append(reader.readLine()); + } catch (IOException ex) { + throw new AcmeException("Could not read resource map", ex); + } + + try { + Map result = JsonUtil.parseJson(sb.toString()); + for (Map.Entry entry : result.entrySet()) { + Resource res = Resource.parse(entry.getKey()); + if (res != null) { + URI uri = new URI(entry.getValue().toString()); + resourceMap.put(res, uri); + } + } + + LOG.debug("Resource directory: {}", resourceMap); + } catch (JoseException | URISyntaxException ex) { + throw new AcmeException("Could not parse resource map: " + sb, ex); + } + + return resourceMap; + } + + /** + * Gets a location from the {@code Location} header. + * + * @return Location {@link URI}, or {@code null} if no Location header was set + */ + public URI getLocation() throws AcmeException { + String location = conn.getHeaderField("Location"); + if (location == null) { + return null; + } + + try { + LOG.debug("Location: {}", location); + return new URI(location); + } catch (URISyntaxException ex) { + throw new AcmeException("Bad Location header: " + location); + } + } + + /** + * Checks if the server returned an error, and if so, throws a {@link AcmeException}. + * + * @throws AcmeException + * if the server returned a JSON problem + */ + private void throwException() throws AcmeException { + if ("application/problem+json".equals(conn.getHeaderField("Content-Type"))) { + Map map = readJsonResponse(); + String type = (String) map.get("type"); + String detail = (String) map.get("detail"); + throw new AcmeServerException(type, detail); + } + } + + /** + * Extracts a nonce from the header. + * + * @param conn + * {@link HttpURLConnection} to read the headers from + * @return Nonce + * @throws AcmeException + * if there was no {@code Replay-Nonce} header, or the nonce was invalid + */ + private static byte[] getNonceFromHeader(HttpURLConnection conn) throws AcmeException { + String nonceHeader = conn.getHeaderField("Replay-Nonce"); + if (nonceHeader == null) { + throw new AcmeException("No replay nonce"); + } + + LOG.debug("Replay Nonce: {}", nonceHeader); + + return Base64Url.decode(nonceHeader); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java new file mode 100644 index 00000000..4d6a41ad --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java @@ -0,0 +1,55 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.connector; + +/** + * Enumeration of resources. + * + * @author Richard "Shred" Körber + */ +public enum Resource { + + NEW_REG("new-reg"), NEW_AUTHZ("new-authz"), NEW_CERT("new-cert"); + + /** + * Parses the string and returns a matching {@link Resource} instance. + * + * @param str + * String to parse + * @return {@link Resource} instance, or {@code null} if the resource is unknown + */ + public static Resource parse(String str) { + for (Resource r : values()) { + if (r.path().equals(str)) { + return r; + } + } + + return null; + } + + private final String path; + + private Resource(String path) { + this.path = path; + } + + /** + * Returns the resource path. + */ + public String path() { + return path; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Session.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Session.java new file mode 100644 index 00000000..9c7d7495 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Session.java @@ -0,0 +1,39 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.connector; + +/** + * A session for tracking communication parameters. + * + * @author Richard "Shred" Körber + */ +public class Session { + + private byte[] nonce; + + /** + * Gets the last nonce, or {@code null} if the session is new. + */ + public byte[] getNonce() { + return nonce; + } + + /** + * Sets the nonce received by the server. + */ + public void setNonce(byte[] nonce) { + this.nonce = nonce; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeException.java b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeException.java new file mode 100644 index 00000000..51fc5aff --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeException.java @@ -0,0 +1,36 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.exception; + +/** + * A generic ACME exception. + * + * @author Richard "Shred" Körber + */ +public class AcmeException extends Exception { + private static final long serialVersionUID = -2935088954705632025L; + + public AcmeException() { + super(); + } + + public AcmeException(String msg) { + super(msg); + } + + public AcmeException(String msg, Throwable cause) { + super(msg, cause); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeServerException.java b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeServerException.java new file mode 100644 index 00000000..392b5462 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeServerException.java @@ -0,0 +1,66 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.exception; + +/** + * An exception that is thrown when the ACME server returned an error. It contains + * further details of the cause. + * + * @author Richard "Shred" Körber + */ +public class AcmeServerException extends AcmeException { + private static final long serialVersionUID = 5971622508467042792L; + + private static final String ACME_ERROR_PREFIX = "urn:acme:error:"; + + private final String type; + + /** + * Creates a new {@link AcmeServerException}. + * + * @param type + * System readable error type (e.g. {@code "urn:acme:error:malformed"}) + * @param detail + * Human readable error message + */ + public AcmeServerException(String type, String detail) { + super(detail); + if (type == null) { + throw new IllegalArgumentException("Error type must not be null"); + } + this.type = type; + } + + /** + * Returns the error type. + */ + public String getType() { + return type; + } + + /** + * Returns the ACME error type. This is the last part of the type URN, e.g. + * {@code "malformed"} on {@code "urn:acme:error:malformed"}. + * + * @return ACME error type, or {@code null} if this is not an {@code "urn:acme:error"} + */ + public String getAcmeErrorType() { + if (type.startsWith(ACME_ERROR_PREFIX)) { + return type.substring(ACME_ERROR_PREFIX.length()); + } else { + return null; + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/impl/AbstractAcmeClient.java b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/AbstractAcmeClient.java new file mode 100644 index 00000000..d9f99de6 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/AbstractAcmeClient.java @@ -0,0 +1,209 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.impl; + +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.AcmeClient; +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.Registration; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.connector.Session; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeServerException; +import org.shredzone.acme4j.util.ClaimBuilder; + +/** + * An abstract implementation of the {@link AcmeClient} interface. It contains abstract + * methods for everything that is provider related. + * + * @author Richard "Shred" Körber + */ +public abstract class AbstractAcmeClient implements AcmeClient { + + private final Session session = new Session(); + + /** + * Gets the {@link URI} for the given {@link Resource}. This may involve connecting to + * the server and getting a directory. The result should be cached in the client. + * + * @param resource + * {@link Resource} to get the {@link URI} of + * @return {@link URI}, or {@code null} if the server does not offer that resource + */ + protected abstract URI resourceUri(Resource resource) throws AcmeException; + + /** + * Creates a {@link Challenge} instance for the given challenge type. + * + * @param type + * Challenge type + * @return {@link Challenge} instance + */ + protected abstract Challenge createChallenge(String type); + + /** + * Connects to the server's API. + * + * @return {@link Connection} instance + */ + protected abstract Connection connect(); + + @Override + public void newRegistration(Account account, Registration registration) throws AcmeException { + try (Connection conn = connect()) { + ClaimBuilder claims = new ClaimBuilder(); + claims.putResource(Resource.NEW_REG); + if (!registration.getContacts().isEmpty()) { + claims.put("contact", registration.getContacts()); + } + if (registration.getAgreementUrl() != null) { + claims.put("agreement", registration.getAgreementUrl()); + } + + try { + conn.sendSignedRequest(resourceUri(Resource.NEW_REG), claims, session, account); + } catch (AcmeServerException ex) { + URI location = conn.getLocation(); + if (location != null) { + registration.setLocation(location); + } + throw ex; + } + } + } + + @Override + public void updateRegistration(Account account, Registration registration) throws AcmeException { + if (registration.getLocation() == null) { + throw new IllegalArgumentException("location must be set. Use newRegistration() if not known."); + } + + try (Connection conn = connect()) { + ClaimBuilder claims = new ClaimBuilder(); + claims.putResource("reg"); + if (!registration.getContacts().isEmpty()) { + claims.put("contact", registration.getContacts()); + } + if (registration.getAgreementUrl() != null) { + claims.put("agreement", registration.getAgreementUrl()); + } + + conn.sendSignedRequest(registration.getLocation(), claims, session, account); + + registration.setLocation(conn.getLocation()); + } + } + + @Override + public void newAuthorization(Account account, Authorization auth) throws AcmeException { + try (Connection conn = connect()) { + ClaimBuilder claims = new ClaimBuilder(); + claims.putResource(Resource.NEW_AUTHZ); + claims.object("identifier") + .put("type", "dns") + .put("value", auth.getDomain()); + + conn.sendSignedRequest(resourceUri(Resource.NEW_AUTHZ), claims, session, account); + + Map result = conn.readJsonResponse(); + + @SuppressWarnings("unchecked") + Collection> challenges = + (Collection>) result.get("challenges"); + List cr = new ArrayList<>(); + for (Map c : challenges) { + Challenge ch = createChallenge((String) c.get("type")); + if (ch != null) { + ch.unmarshall(c); + cr.add(ch); + } + } + auth.setChallenges(cr); + + @SuppressWarnings("unchecked") + Collection> combinations = + (Collection>) result.get("combinations"); + if (combinations != null) { + List> cmb = new ArrayList<>(combinations.size()); + for (List c : combinations) { + List clist = new ArrayList<>(c.size()); + for (Number n : c) { + clist.add(cr.get(n.intValue())); + } + cmb.add(clist); + } + auth.setCombinations(cmb); + } else { + List> cmb = new ArrayList<>(1); + cmb.add(cr); + auth.setCombinations(cmb); + } + } + } + + @Override + public void triggerChallenge(Account account, Challenge challenge) throws AcmeException { + try (Connection conn = connect()) { + ClaimBuilder claims = new ClaimBuilder(); + claims.putResource("challenge"); + challenge.marshall(claims); + + conn.sendSignedRequest(challenge.getUri(), claims, session, account); + + challenge.unmarshall(conn.readJsonResponse()); + } + } + + @Override + public void updateChallenge(Account account, Challenge challenge) throws AcmeException { + try (Connection conn = connect()) { + conn.sendRequest(challenge.getUri()); + challenge.unmarshall(conn.readJsonResponse()); + } + } + + @Override + public URI requestCertificate(Account account, byte[] csr) throws AcmeException { + try (Connection conn = connect()) { + ClaimBuilder claims = new ClaimBuilder(); + claims.putResource(Resource.NEW_CERT); + claims.putBase64("csr", csr); + + conn.sendSignedRequest(resourceUri(Resource.NEW_CERT), claims, session, account); + + // Optionally returns the certificate. Currently it is just ignored. + // X509Certificate cert = conn.readCertificate(); + + return conn.getLocation(); + } + } + + @Override + public X509Certificate downloadCertificate(URI certUri) throws AcmeException { + try (Connection conn = connect()) { + conn.sendRequest(certUri); + return conn.readCertificate(); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/impl/GenericAcmeClient.java b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/GenericAcmeClient.java new file mode 100644 index 00000000..cafa1531 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/GenericAcmeClient.java @@ -0,0 +1,73 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.impl; + +import java.net.URI; +import java.util.EnumMap; +import java.util.Map; + +import org.shredzone.acme4j.AcmeClient; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.provider.AcmeClientProvider; + +/** + * A generic implementation of {@link AcmeClient}. It uses an {@link AcmeClientProvider} + * for managing individual server features. + * + * @author Richard "Shred" Körber + */ +public class GenericAcmeClient extends AbstractAcmeClient { + + private final AcmeClientProvider provider; + private final URI directoryUri; + private final Map directoryMap = new EnumMap<>(Resource.class); + + /** + * Creates a new {@link GenericAcmeClient}. + * + * @param provider + * {@link AcmeClientProvider} creating this client + * @param directoryUri + * {@link URI} of the ACME server's directory service + */ + public GenericAcmeClient(AcmeClientProvider provider, URI directoryUri) { + this.provider = provider; + this.directoryUri = directoryUri; + } + + @Override + protected Challenge createChallenge(String type) { + return provider.createChallenge(type); + } + + @Override + protected Connection connect() { + return new Connection(provider); + } + + @Override + protected URI resourceUri(Resource resource) throws AcmeException { + if (directoryMap.isEmpty()) { + try (Connection conn = connect()) { + conn.sendRequest(directoryUri); + directoryMap.putAll(conn.readResourceMap()); + } + } + return directoryMap.get(resource); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java new file mode 100644 index 00000000..51485cc2 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AbstractAcmeClientProvider.java @@ -0,0 +1,98 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.provider; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.challenge.DnsChallenge; +import org.shredzone.acme4j.challenge.GenericChallenge; +import org.shredzone.acme4j.challenge.HttpChallenge; +import org.shredzone.acme4j.challenge.ProofOfPossessionChallenge; +import org.shredzone.acme4j.challenge.TlsSniChallenge; + +/** + * Abstract implementation of {@link AcmeClientProvider}. It consists of a challenge + * registry and a standard {@link #openConnection(URI)} implementation. + * + * @author Richard "Shred" Körber + */ +public abstract class AbstractAcmeClientProvider implements AcmeClientProvider { + + private static final int TIMEOUT = 10000; + + private final Map> challenges = new HashMap<>(); + + public AbstractAcmeClientProvider() { + registerBaseChallenges(); + } + + @Override + @SuppressWarnings("unchecked") + public T createChallenge(String type) { + Class clazz = challenges.get(type); + if (clazz == null) { + return (T) new GenericChallenge(); + } + + try { + return (T) clazz.newInstance(); + } catch (InstantiationException | IllegalAccessException ex) { + throw new IllegalArgumentException("Could not create Challenge for type " + + type, ex); + } + } + + @Override + public HttpURLConnection openConnection(URI uri) throws IOException { + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setConnectTimeout(TIMEOUT); + conn.setReadTimeout(TIMEOUT); + conn.setUseCaches(false); + conn.setRequestProperty("User-Agent", "acme4j"); + return conn; + } + + /** + * Registers an individual {@link Challenge}. If a challenge of that type is already + * registered, it will be replaced. + * + * @param type + * Challenge type string + * @param clazz + * Class implementing the {@link Challenge}. It must have a default + * constructor. + */ + protected void registerChallenge(String type, Class clazz) { + challenges.put(type, clazz); + } + + /** + * Registers all standard challenges as specified in the ACME specifications. + *

+ * Subclasses may override this method in order to add further challenges. It is + * invoked on construction time. + */ + protected void registerBaseChallenges() { + registerChallenge(DnsChallenge.TYPE, DnsChallenge.class); + registerChallenge(TlsSniChallenge.TYPE, TlsSniChallenge.class); + registerChallenge(ProofOfPossessionChallenge.TYPE, ProofOfPossessionChallenge.class); + registerChallenge(HttpChallenge.TYPE, HttpChallenge.class); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeClientProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeClientProvider.java new file mode 100644 index 00000000..d02c6bd1 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/AcmeClientProvider.java @@ -0,0 +1,82 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.provider; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.util.ServiceLoader; + +import org.shredzone.acme4j.AcmeClient; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.challenge.GenericChallenge; + +/** + * An {@link AcmeClientProvider} creates {@link AcmeClient} instances to be used for + * communicating with the ACME server. Implementations handle individual features of each + * ACME server. + *

+ * Provider implementations must be registered with Java's {@link ServiceLoader}. + * + * @author Richard "Shred" Körber + */ +public interface AcmeClientProvider { + + /** + * Checks if this provider accepts the given server URI. + * + * @param serverUri + * Server URI to test + * @return {@code true} if this provider accepts the server URI, {@code false} + * otherwise + */ + boolean accepts(String serverUri); + + /** + * Connects to an {@link AcmeClient} for communication with the ACME server. + * + * @param serverUri + * Server URI to connect to + * @return {@link AcmeClient} connected to the server + */ + AcmeClient connect(String serverUri); + + /** + * Creates a {@link Challenge} instance that is able to respond to the challenge of + * the given type. + * + * @param type + * Challenge type name + * @return Matching {@link Challenge} instance + * @throws ClassCastException + * if the expected {@link Challenge} type does not match the given type + * name. + * @throws IllegalArgumentException + * if the given type name cannot be resolved to any {@link Challenge} + * class. However, for unknown challenge types, a {@link GenericChallenge} + * instance should be returned. + */ + T createChallenge(String type); + + /** + * Opens a {@link HttpURLConnection} to the given {@link URI}. Implementations may + * configure the connection, e.g. pin it to a concrete SSL certificate. + * + * @param uri + * {@link URI} to connect to + * @return {@link HttpURLConnection} connected to the {@link URI} + */ + HttpURLConnection openConnection(URI uri) throws IOException; + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/GenericAcmeClientProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/GenericAcmeClientProvider.java new file mode 100644 index 00000000..f5dc08ae --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/GenericAcmeClientProvider.java @@ -0,0 +1,51 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.provider; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.shredzone.acme4j.AcmeClient; +import org.shredzone.acme4j.impl.GenericAcmeClient; + +/** + * A generic {@link AcmeClientProvider}. It should be working for all ACME servers + * complying to the ACME specifications. + *

+ * The {@code serverUri} is either a http or https URI to the server's directory service. + * + * @author Richard "Shred" Körber + */ +public class GenericAcmeClientProvider extends AbstractAcmeClientProvider { + + @Override + public boolean accepts(String serverUri) { + return serverUri.startsWith("http://") || serverUri.startsWith("https://"); + } + + @Override + public AcmeClient connect(String serverUri) { + if (!accepts(serverUri)) { + throw new IllegalArgumentException("This provider does not accept " + serverUri); + } + + try { + URI directoryUri = new URI(serverUri); + return new GenericAcmeClient(this, directoryUri); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException(serverUri, ex); + } + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/util/ClaimBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/util/ClaimBuilder.java new file mode 100644 index 00000000..59b316bc --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/util/ClaimBuilder.java @@ -0,0 +1,159 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import java.security.Key; +import java.util.Map; +import java.util.TreeMap; + +import org.jose4j.base64url.Base64Url; +import org.jose4j.json.JsonUtil; +import org.jose4j.jwk.JsonWebKey; +import org.jose4j.lang.JoseException; +import org.shredzone.acme4j.connector.Resource; + +/** + * Builder for claim structures. + *

+ * Example: + *

+ * ClaimBuilder cb = new ClaimBuilder();
+ * cb.put("foo", 123).put("bar", "hello world");
+ * cb.object("sub").put("data", "subdata");
+ * cb.array("array", 123, 456, 789);
+ * 
+ * + * @author Richard "Shred" Körber + */ +public class ClaimBuilder { + + private final Map data = new TreeMap<>(); + + /** + * Puts a claim. If a claim with the key exists, it will be replaced. + * + * @param key + * Claim key + * @param value + * Claim value + * @return {@code this} + */ + public ClaimBuilder put(String key, Object value) { + data.put(key, value); + return this; + } + + /** + * Puts a resource claim. + * + * @param resource + * Resource name + * @return {@code this} + */ + public ClaimBuilder putResource(String resource) { + return put("resource", resource); + } + + /** + * Puts a resource claim. + * + * @param resource + * {@link Resource} + * @return {@code this} + */ + public ClaimBuilder putResource(Resource resource) { + return putResource(resource.path()); + } + + /** + * Puts an entire map into the claim. + * + * @param map + * Map to put + * @return {@code this} + */ + public ClaimBuilder putAll(Map map) { + data.putAll(map); + return this; + } + + /** + * Puts binary data to the claim. The data is base64 url encoded. + * + * @param key + * Claim key + * @param data + * Claim data + * @return {@code this} + */ + public ClaimBuilder putBase64(String key, byte[] data) { + return put(key, Base64Url.encode(data)); + } + + /** + * Puts a {@link Key} into the claim. The key is serializied as JWK. + * + * @param key + * Claim key + * @param publickey + * {@link Key} to serialize + * @return {@code this} + */ + public ClaimBuilder putKey(String key, Key publickey) { + try { + final JsonWebKey jwk = JsonWebKey.Factory.newJwk(publickey); + Map jwkParams = jwk.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY); + object(key).putAll(jwkParams); + return this; + } catch (JoseException ex) { + throw new IllegalArgumentException("Invalid key", ex); + } + } + + /** + * Creates a sub-claim for the given key. + * + * @param key + * Key of the sub-claim + * @return Newly created {@link ClaimBuilder} for the sub-claim. + */ + public ClaimBuilder object(String key) { + ClaimBuilder subBuilder = new ClaimBuilder(); + data.put(key, subBuilder.data); + return subBuilder; + } + + /** + * Puts an array claim. + * + * @param key + * Claim key + * @param values + * Array of claim values + * @return {@code this} + */ + public ClaimBuilder array(String key, Object... values) { + data.put(key, values); + return this; + } + + /** + * Returns a JSON representation of the claims. + */ + @Override + public String toString() { + return JsonUtil.toJson(data); + } + +} diff --git a/acme4j-client/src/main/resources/.gitignore b/acme4j-client/src/main/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider b/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider new file mode 100644 index 00000000..70f99082 --- /dev/null +++ b/acme4j-client/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider @@ -0,0 +1,2 @@ +# Generic +org.shredzone.acme4j.provider.GenericAcmeClientProvider diff --git a/acme4j-client/src/site/apt/index.apt b/acme4j-client/src/site/apt/index.apt new file mode 100644 index 00000000..800d73ee --- /dev/null +++ b/acme4j-client/src/site/apt/index.apt @@ -0,0 +1 @@ + This is the client part of the Java ACME client. diff --git a/acme4j-client/src/site/resources/css/site.css b/acme4j-client/src/site/resources/css/site.css new file mode 100644 index 00000000..992965e2 --- /dev/null +++ b/acme4j-client/src/site/resources/css/site.css @@ -0,0 +1,6 @@ +a.externalLink, a.externalLink:link, a.externalLink:visited, a.externalLink:active, a.externalLink:hover { + background: none; + padding-right: 0; +} + + diff --git a/acme4j-client/src/site/site.xml b/acme4j-client/src/site/site.xml new file mode 100644 index 00000000..9416a427 --- /dev/null +++ b/acme4j-client/src/site/site.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/acme4j-client/src/test/java/.gitignore b/acme4j-client/src/test/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-client/src/test/resources/.gitignore b/acme4j-client/src/test/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-example/.gitignore b/acme4j-example/.gitignore new file mode 100644 index 00000000..20ab03b7 --- /dev/null +++ b/acme4j-example/.gitignore @@ -0,0 +1,3 @@ +*.key +*.crt +*.csr diff --git a/acme4j-example/.project b/acme4j-example/.project new file mode 100644 index 00000000..f9f2fc07 --- /dev/null +++ b/acme4j-example/.project @@ -0,0 +1,23 @@ + + + acme4j-example + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/acme4j-example/pom.xml b/acme4j-example/pom.xml new file mode 100644 index 00000000..6eaa3056 --- /dev/null +++ b/acme4j-example/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + + + org.shredzone.acme4j + acme4j + 0.1-SNAPSHOT + + + acme4j-example + + acme4j example + Example for using acme4j + + + + org.shredzone.acme4j + acme4j-client + ${project.version} + + + org.shredzone.acme4j + acme4j-letsencrypt + ${project.version} + + + org.shredzone.acme4j + acme4j-utils + ${project.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + runtime + + + + diff --git a/acme4j-example/src/main/java/.gitignore b/acme4j-example/src/main/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java new file mode 100644 index 00000000..10442bec --- /dev/null +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java @@ -0,0 +1,206 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.net.URI; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; + +import javax.swing.JOptionPane; + +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.challenge.HttpChallenge; +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A simple client test tool. + *

+ * Pass the names of the domains as parameters. + * + * @author Richard "Shred" Körber + */ +public class ClientTest { + private static final String AGREEMENT_URL = "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"; + + private static final File USER_KEY_FILE = new File("user.key"); + private static final File DOMAIN_KEY_FILE = new File("domain.key"); + private static final File DOMAIN_CERT_FILE = new File("domain.crt"); + private static final File DOMAIN_CSR_FILE = new File("domain.crt"); + + private static final int KEY_SIZE = 2048; + + private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class); + + /** + * Generates a certificate for the given domains. Also takes care for the registration + * process. + * + * @param domains + * Domains to get a common certificate for + * @param agreementUrl + * Agreement URL to be used for creating an account + */ + public void fetchCertificate(Collection domains, String agreementUrl) + throws IOException, AcmeException { + // Load or create a key pair for the user's account + KeyPair userKeyPair; + if (USER_KEY_FILE.exists()) { + try (FileReader fr = new FileReader(USER_KEY_FILE)) { + userKeyPair = KeyPairUtils.readKeyPair(fr); + } + } else { + userKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); + try (FileWriter fw = new FileWriter(USER_KEY_FILE)) { + KeyPairUtils.writeKeyPair(userKeyPair, fw); + } + } + + // Create an Account instance for the user + Account account = new Account(userKeyPair); + + // Create an AcmeClient for Let's Encrypt + AcmeClient client = AcmeClientFactory.connect("acme://letsencrypt.org"); + + // Register a new user + Registration reg = new Registration(); + reg.setAgreementUrl(agreementUrl); + try { + client.newRegistration(account, reg); + LOG.info("Registered a new user, URI: " + reg.getLocation()); + } catch (AcmeException ex) { + LOG.warn("Registration failed", ex); + + // Try to update the user's account, maybe there was a new agreement url? + try { + client.updateRegistration(account, reg); + LOG.info("Updated user, URI: " + reg.getLocation()); + } catch (AcmeException ex2) { + LOG.warn("Registration update failed, too. Giving up!", ex2); + return; + } + } + + for (String domain : domains) { + // Create a new authorization + Authorization auth = new Authorization(); + auth.setDomain(domain); + client.newAuthorization(account, auth); + LOG.info("New authorization for domain " + domain); + + // Find a single http-01 challenge + HttpChallenge challenge = auth.findChallenge(HttpChallenge.TYPE); + if (challenge == null) { + LOG.error("Found no " + HttpChallenge.TYPE + " challenge, don't know what to do..."); + return; + } + + // Authorize the challenge + challenge.authorize(account); + + // Output the challenge, wait for acknowledge... + LOG.info("Please create a file in your web server's base directory."); + LOG.info("It must be reachable at: http://" + domain + "/.well-known/acme-challenge/" + challenge.getToken()); + LOG.info("File name: " + challenge.getToken()); + LOG.info("Content: " + challenge.getAuthorization()); + LOG.info("The file must not contain any leading or trailing whitespaces or line breaks!"); + LOG.info("If you're ready, dismiss the dialog..."); + JOptionPane.showMessageDialog(null, "OK?"); + + // Trigger the challenge + client.triggerChallenge(account, challenge); + + // Poll for the challenge to complete + int attempts = 10; + while (challenge.getStatus() != Challenge.Status.VALID && attempts-- > 0) { + if (challenge.getStatus() == Challenge.Status.INVALID) { + LOG.error("Challenge failed... Giving up."); + return; + } + try { + Thread.sleep(3000L); + } catch (InterruptedException ex) { + LOG.warn("interrupted", ex); + } + client.updateChallenge(account, challenge); + } + if (attempts == 0) { + LOG.error("Failed to pass the challenge... Giving up."); + return; + } + } + + // Load or create a key pair for the domain + KeyPair domainKeyPair; + if (DOMAIN_KEY_FILE.exists()) { + try (FileReader fr = new FileReader(DOMAIN_KEY_FILE)) { + domainKeyPair = KeyPairUtils.readKeyPair(fr); + } + } else { + domainKeyPair = KeyPairUtils.createKeyPair(KEY_SIZE); + try (FileWriter fw = new FileWriter(DOMAIN_KEY_FILE)) { + KeyPairUtils.writeKeyPair(domainKeyPair, fw); + } + } + + // Generate a CSR for the domain + CSRBuilder csrb = new CSRBuilder(); + csrb.addDomains(domains); + csrb.sign(domainKeyPair); + + try (Writer out = new FileWriter(DOMAIN_CSR_FILE)) { + csrb.write(out); + } + + // Request a signed certificate + URI certificateUri = client.requestCertificate(account, csrb.getEncoded()); + LOG.info("Success! The certificate for domains " + domains + " has been generated!"); + LOG.info("Certificate URI: " + certificateUri); + + // Download the certificate + X509Certificate cert = client.downloadCertificate(certificateUri); + try (FileWriter fw = new FileWriter(DOMAIN_CERT_FILE)) { + CertificateUtils.writeX509Certificate(cert, fw); + } + } + + public static void main(String... args) { + if (args.length == 0) { + System.err.println("Usage: ClientTest ..."); + System.exit(1); + } + + LOG.info("Starting up..."); + + Collection domains = Arrays.asList(args); + try { + ClientTest ct = new ClientTest(); + ct.fetchCertificate(domains, AGREEMENT_URL); + } catch (Exception ex) { + LOG.error("Failed to get a certificate for domains " + domains, ex); + } + } + +} diff --git a/acme4j-example/src/main/resources/.gitignore b/acme4j-example/src/main/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-example/src/main/resources/simplelogger.properties b/acme4j-example/src/main/resources/simplelogger.properties new file mode 100644 index 00000000..9d089390 --- /dev/null +++ b/acme4j-example/src/main/resources/simplelogger.properties @@ -0,0 +1,2 @@ + +org.slf4j.simpleLogger.log.org.shredzone.acme4j = debug diff --git a/acme4j-example/src/site/apt/index.apt b/acme4j-example/src/site/apt/index.apt new file mode 100644 index 00000000..29f0a921 --- /dev/null +++ b/acme4j-example/src/site/apt/index.apt @@ -0,0 +1 @@ + An example about how to use the Java ACME client. diff --git a/acme4j-example/src/site/resources/css/site.css b/acme4j-example/src/site/resources/css/site.css new file mode 100644 index 00000000..992965e2 --- /dev/null +++ b/acme4j-example/src/site/resources/css/site.css @@ -0,0 +1,6 @@ +a.externalLink, a.externalLink:link, a.externalLink:visited, a.externalLink:active, a.externalLink:hover { + background: none; + padding-right: 0; +} + + diff --git a/acme4j-example/src/site/site.xml b/acme4j-example/src/site/site.xml new file mode 100644 index 00000000..acd3b953 --- /dev/null +++ b/acme4j-example/src/site/site.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + +

+ + + + + + + diff --git a/acme4j-example/src/test/java/.gitignore b/acme4j-example/src/test/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-example/src/test/resources/.gitignore b/acme4j-example/src/test/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-letsencrypt/.project b/acme4j-letsencrypt/.project new file mode 100644 index 00000000..c27bcaa8 --- /dev/null +++ b/acme4j-letsencrypt/.project @@ -0,0 +1,23 @@ + + + acme4j-letsencrypt + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/acme4j-letsencrypt/pom.xml b/acme4j-letsencrypt/pom.xml new file mode 100644 index 00000000..06798524 --- /dev/null +++ b/acme4j-letsencrypt/pom.xml @@ -0,0 +1,38 @@ + + + + 4.0.0 + + + org.shredzone.acme4j + acme4j + 0.1-SNAPSHOT + + + acme4j-letsencrypt + + acme4j Let's Encrypt + Let's Encrypt service provider for acme4j + + + + org.shredzone.acme4j + acme4j-client + ${parent.version} + + + diff --git a/acme4j-letsencrypt/src/main/java/.gitignore b/acme4j-letsencrypt/src/main/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-letsencrypt/src/main/java/org/shredzone/acme4j/provider/LetsEncryptAcmeClientProvider.java b/acme4j-letsencrypt/src/main/java/org/shredzone/acme4j/provider/LetsEncryptAcmeClientProvider.java new file mode 100644 index 00000000..1696b5a3 --- /dev/null +++ b/acme4j-letsencrypt/src/main/java/org/shredzone/acme4j/provider/LetsEncryptAcmeClientProvider.java @@ -0,0 +1,117 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.provider; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +import org.shredzone.acme4j.AcmeClient; +import org.shredzone.acme4j.impl.GenericAcmeClient; + +/** + * An {@link AcmeClientProvider} for Let's Encrypt. + *

+ * The {@code serverUri} is {@code "acme://letsencrypt.org"} for the production server, and + * {@code "acme://letsencrypt.org/staging"} for a testing server. + *

+ * If you want to use Let's Encrypt, always prefer to use this provider, as it + * takes care for the correct connection and SSL certificates. + * + * @author Richard "Shred" Körber + * @see Let's Encrypt + */ +public class LetsEncryptAcmeClientProvider extends AbstractAcmeClientProvider { + + private static final String V01_DIRECTORY_URI = "https://acme-v01.api.letsencrypt.org/directory"; + private static final String STAGING_DIRECTORY_URI = "https://acme-staging.api.letsencrypt.org/directory"; + + private SSLSocketFactory sslSocketFactory; + + @Override + public boolean accepts(String serverUri) { + return serverUri.startsWith("acme://letsencrypt.org"); + } + + @Override + public AcmeClient connect(String serverUri) { + String directoryUri; + switch (serverUri) { + case "acme://letsencrypt.org/staging": + directoryUri = STAGING_DIRECTORY_URI; + break; + + case "acme://letsencrypt.org/v01": + case "acme://letsencrypt.org": + directoryUri = V01_DIRECTORY_URI; + break; + + default: + throw new IllegalArgumentException("Unknown URI " + serverUri); + } + + try { + return new GenericAcmeClient(this, new URI(directoryUri)); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException(directoryUri, ex); + } + } + + @Override + public HttpURLConnection openConnection(URI uri) throws IOException { + HttpURLConnection conn = super.openConnection(uri); + if (conn instanceof HttpsURLConnection) { + ((HttpsURLConnection) conn).setSSLSocketFactory(createSocketFactory()); + } + return conn; + } + + /** + * Lazily creates an {@link SSLSocketFactory} that exclusively accepts the Let's + * Encrypt certificate. + */ + private SSLSocketFactory createSocketFactory() throws IOException { + if (sslSocketFactory == null) { + try { + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + keystore.load(getClass().getResourceAsStream("/org/shredzone/acme4j/letsencrypt.truststore"), + "acme4j".toCharArray()); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keystore); + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, tmf.getTrustManagers(), null); + + sslSocketFactory = ctx.getSocketFactory(); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException + | KeyManagementException ex) { + throw new IOException("Could not create truststore", ex); + } + } + return sslSocketFactory; + } + +} diff --git a/acme4j-letsencrypt/src/main/resources/.gitignore b/acme4j-letsencrypt/src/main/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-letsencrypt/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider b/acme4j-letsencrypt/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider new file mode 100644 index 00000000..26881f11 --- /dev/null +++ b/acme4j-letsencrypt/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.AcmeClientProvider @@ -0,0 +1,2 @@ +# Let's Encrypt: https://letsencrypt.org +org.shredzone.acme4j.provider.LetsEncryptAcmeClientProvider diff --git a/acme4j-letsencrypt/src/main/resources/org/shredzone/acme4j/letsencrypt.truststore b/acme4j-letsencrypt/src/main/resources/org/shredzone/acme4j/letsencrypt.truststore new file mode 100644 index 0000000000000000000000000000000000000000..f0631cb44e66cb48ae62376dd232f3ea446a868f GIT binary patch literal 1878 zcmezO_TO6u1_mY|W(3n*MX9;@C8@=kC8)FR?K>|cBR4C9L6jl40Vf-CC<~h~Q)sZE zkbwY*!zIk=nUb0pQdC-8VkmAP3KHWM<^%IRT@->-i^@`q4CM@DKoZQtqNoxI&W;L> zrbY&G;=G2Y24+TPhUNyQCZ7 zUP)qRoh&j1wXVrpV! zWSHi3aM{;9OS9~yi=UiJF#J7vj`j-qn#C{XAGDfuDb4J}{dKYjd~a}VkKI~$;NP3x z2du~Pp1y9o7G5;x{fFcE85J3hU8)pVN=B{;9;OIS`Y|wJ#+H1{cyUyxdHq+@gb$jjqcKgPCkMnO{+@$W^tC(bwlskF* ztmlC?)nRA876Z3onK456d@-s62XJKJxVqIX+!~}{WSp}9R zCOIHS3K(qMY+MO#9*k{2^%>Zh4I2L#IIwYOvoW%=vNJJ?nPild6ck(O>lddcmlmb! zWdajg2`HiICFkerC#M#bWTs^%Cjt|=enEatW^$!|NwK~`<94_aOa_gsq#BopWMmdA zpeHZ@S+ zrCq?eVFhpXi@mnDtP{2;@g(d2bJ}#$IA?Wdje^^ZYd+>#D~{K%OVV!9$64z0x1acw(;AktELEXfa;>tI(648%hBLBuJ9@pz zvNlrYxRXE0CD*XwY@$Tc>Bas=7fdEixWI95trdrOiRRXCo|`rLHG{seaTTs9EXtqC zB$qk+)`ish0k8WrU)G=N2njm5ZOSZzDV<5*>jTS0rKWon>_6>%J*(m51lHUCcbr}u zySZCm!rpP0bNU9JOLtt)u3DEB5jD|SV{+<;IlW7dl--$h;(|!Wxp|^y4ScG7Tb_IO RJFq!jxmRA%W_w^)D*!c`k-h)` literal 0 HcmV?d00001 diff --git a/acme4j-letsencrypt/src/site/apt/index.apt b/acme4j-letsencrypt/src/site/apt/index.apt new file mode 100644 index 00000000..aa2a6976 --- /dev/null +++ b/acme4j-letsencrypt/src/site/apt/index.apt @@ -0,0 +1 @@ + Let's Encrypt service provider for the Java ACME client. diff --git a/acme4j-letsencrypt/src/site/resources/css/site.css b/acme4j-letsencrypt/src/site/resources/css/site.css new file mode 100644 index 00000000..992965e2 --- /dev/null +++ b/acme4j-letsencrypt/src/site/resources/css/site.css @@ -0,0 +1,6 @@ +a.externalLink, a.externalLink:link, a.externalLink:visited, a.externalLink:active, a.externalLink:hover { + background: none; + padding-right: 0; +} + + diff --git a/acme4j-letsencrypt/src/site/site.xml b/acme4j-letsencrypt/src/site/site.xml new file mode 100644 index 00000000..d94a2741 --- /dev/null +++ b/acme4j-letsencrypt/src/site/site.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + +

+ + + + + + + diff --git a/acme4j-letsencrypt/src/test/java/.gitignore b/acme4j-letsencrypt/src/test/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-letsencrypt/src/test/resources/.gitignore b/acme4j-letsencrypt/src/test/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-utils/.project b/acme4j-utils/.project new file mode 100644 index 00000000..eb1e5a55 --- /dev/null +++ b/acme4j-utils/.project @@ -0,0 +1,23 @@ + + + acme4j-utils + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/acme4j-utils/pom.xml b/acme4j-utils/pom.xml new file mode 100644 index 00000000..c2b40c2e --- /dev/null +++ b/acme4j-utils/pom.xml @@ -0,0 +1,54 @@ + + + + 4.0.0 + + + org.shredzone.acme4j + acme4j + 0.1-SNAPSHOT + + + acme4j-utils + + acme4j utils + acme4j utilities + + + + org.shredzone.acme4j + acme4j-client + ${project.version} + + + org.bouncycastle + bcpg-jdk15on + ${bouncycastle.version} + + + org.bouncycastle + bcmail-jdk15on + ${bouncycastle.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + diff --git a/acme4j-utils/src/main/java/.gitignore b/acme4j-utils/src/main/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CSRBuilder.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CSRBuilder.java new file mode 100644 index 00000000..f15118bd --- /dev/null +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CSRBuilder.java @@ -0,0 +1,212 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import java.io.IOException; +import java.io.Writer; +import java.security.KeyPair; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; + +/** + * Generator for a CSR (Certificate Signing Request) suitable for ACME servers. + *

+ * Requires {@code Bouncy Castle}. + * + * @author Richard "Shred" Körber + */ +public class CSRBuilder { + private static final String SIGNATURE_ALG = "SHA256withRSA"; + + private final X500NameBuilder namebuilder = new X500NameBuilder(X500Name.getDefaultStyle()); + private final List namelist = new ArrayList<>(); + private PKCS10CertificationRequest csr = null; + + /** + * Adds a domain name to the CSR. The first domain name added will also be the + * Common Name. All domain names will be added as Subject + * Alternative Name. + *

+ * Note that ACME servers may not accept wildcard domains! + */ + public void addDomain(String domain) { + if (namelist.isEmpty()) { + namebuilder.addRDN(BCStyle.CN, domain); + } + namelist.add(domain); + } + + /** + * Adds a {@link Collection} of domains. + */ + public void addDomains(Collection domains) { + for (String domain : domains) { + addDomain(domain); + } + } + + /** + * Adds multiple domain names. + */ + public void addDomains(String... domains) { + for (String domain : domains) { + addDomain(domain); + } + } + + /** + * Sets the organization. + *

+ * Note that it is at the discretion of the ACME server to accept this parameter. + */ + public void setOrganization(String o) { + namebuilder.addRDN(BCStyle.O, o); + } + + /** + * Sets the organizational unit. + *

+ * Note that it is at the discretion of the ACME server to accept this parameter. + */ + public void setOrganizationalUnit(String ou) { + namebuilder.addRDN(BCStyle.OU, ou); + } + + /** + * Sets the city or locality. + *

+ * Note that it is at the discretion of the ACME server to accept this parameter. + */ + public void setLocality(String l) { + namebuilder.addRDN(BCStyle.L, l); + } + + /** + * Sets the state or province. + *

+ * Note that it is at the discretion of the ACME server to accept this parameter. + */ + public void setState(String st) { + namebuilder.addRDN(BCStyle.ST, st); + } + + /** + * Sets the country. + *

+ * Note that it is at the discretion of the ACME server to accept this parameter. + */ + public void setCountry(String c) { + namebuilder.addRDN(BCStyle.C, c); + } + + /** + * Signs the completed CSR. + * + * @param keypair + * {@link KeyPair} to sign the CSR with + */ + public void sign(KeyPair keypair) throws IOException { + if (namelist.isEmpty()) { + throw new IllegalStateException("No domain was set"); + } + if (keypair == null) { + throw new IllegalArgumentException("keypair must not be null"); + } + + try { + GeneralName[] gns = new GeneralName[namelist.size()]; + for (int ix = 0; ix < namelist.size(); ix++) { + gns[ix] = new GeneralName(GeneralName.dNSName, namelist.get(ix)); + } + GeneralNames subjectAltName = new GeneralNames(gns); + + PKCS10CertificationRequestBuilder p10Builder = + new JcaPKCS10CertificationRequestBuilder(namebuilder.build(), keypair.getPublic()); + + ExtensionsGenerator extensionsGenerator = new ExtensionsGenerator(); + extensionsGenerator.addExtension(Extension.subjectAlternativeName, false, subjectAltName); + p10Builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionsGenerator.generate()); + + JcaContentSignerBuilder csBuilder = new JcaContentSignerBuilder(SIGNATURE_ALG); + ContentSigner signer = csBuilder.build(keypair.getPrivate()); + + csr = p10Builder.build(signer); + } catch (OperatorCreationException ex) { + throw new IOException("Could not generate CSR", ex); + } + } + + /** + * Gets the PKCS#10 certification request. + */ + public PKCS10CertificationRequest getCSR() { + if (csr == null) { + throw new IllegalStateException("sign CSR first"); + } + + return csr; + } + + /** + * Gets an encoded PKCS#10 certification request. + */ + public byte[] getEncoded() throws IOException { + return getCSR().getEncoded(); + } + + /** + * Writes the signed certificate request to a file. + * + * @param w + * {@link Writer} to write the PEM file to + */ + public void write(Writer w) throws IOException { + if (csr == null) { + throw new IllegalStateException("sign CSR first"); + } + + try (PemWriter pw = new PemWriter(w)) { + pw.writeObject(new PemObject("CERTIFICATE REQUEST", getEncoded())); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(namebuilder.build()); + for (String domain : namelist) { + sb.append(",DNS=").append(domain.toString()); + } + return sb.toString(); + } + +} diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java new file mode 100644 index 00000000..3769e767 --- /dev/null +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/CertificateUtils.java @@ -0,0 +1,49 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import java.io.IOException; +import java.io.Writer; +import java.security.cert.X509Certificate; + +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + +/** + * Utility class offering convenience methods for certificates. + *

+ * Requires {@code Bouncy Castle}. + * + * @author Richard "Shred" Körber + */ +public final class CertificateUtils { + + private CertificateUtils() { + // utility class without constructor + } + + /** + * Writes an X.509 certificate PEM file. + * + * @param cert + * {@link X509Certificate} to write + * @param w + * {@link Writer} to write the PEM file to + */ + public static void writeX509Certificate(X509Certificate cert, Writer w) throws IOException { + try (JcaPEMWriter jw = new JcaPEMWriter(w)) { + jw.writeObject(cert); + } + } + +} diff --git a/acme4j-utils/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java new file mode 100644 index 00000000..7b637e9b --- /dev/null +++ b/acme4j-utils/src/main/java/org/shredzone/acme4j/util/KeyPairUtils.java @@ -0,0 +1,89 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; + +import org.bouncycastle.openssl.PEMException; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + +/** + * Utility class offering convenience methods for {@link KeyPair}. + *

+ * Requires {@code Bouncy Castle}. + * + * @author Richard "Shred" Körber + */ +public class KeyPairUtils { + + private KeyPairUtils() { + // utility class without constructor + } + + /** + * Creates a new RSA {@link KeyPair}. + * + * @param keysize + * Key size + * @return Generated {@link KeyPair} + */ + public static KeyPair createKeyPair(int keysize) { + try { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(keysize); + return keyGen.generateKeyPair(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Reads a {@link KeyPair} from a PEM file. + * + * @param r + * {@link Reader} to read the PEM file from + * @return {@link KeyPair} read + */ + public static KeyPair readKeyPair(Reader r) throws IOException { + try (PEMParser parser = new PEMParser(r)) { + PEMKeyPair keyPair = (PEMKeyPair) parser.readObject(); + return new JcaPEMKeyConverter().getKeyPair(keyPair); + } catch (PEMException ex) { + throw new IOException("Invalid PEM file", ex); + } + } + + /** + * Writes a {@link KeyPair} PEM file. + * + * @param keypair + * {@link KeyPair} to write + * @param w + * {@link Writer} to write the PEM file to + */ + public static void writeKeyPair(KeyPair keypair, Writer w) throws IOException { + try (JcaPEMWriter jw = new JcaPEMWriter(w)) { + jw.writeObject(keypair); + } + } + +} diff --git a/acme4j-utils/src/main/resources/.gitignore b/acme4j-utils/src/main/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-utils/src/site/apt/index.apt b/acme4j-utils/src/site/apt/index.apt new file mode 100644 index 00000000..bee99a0f --- /dev/null +++ b/acme4j-utils/src/site/apt/index.apt @@ -0,0 +1 @@ + Some utility classes for using the Java ACME client. diff --git a/acme4j-utils/src/site/resources/css/site.css b/acme4j-utils/src/site/resources/css/site.css new file mode 100644 index 00000000..992965e2 --- /dev/null +++ b/acme4j-utils/src/site/resources/css/site.css @@ -0,0 +1,6 @@ +a.externalLink, a.externalLink:link, a.externalLink:visited, a.externalLink:active, a.externalLink:hover { + background: none; + padding-right: 0; +} + + diff --git a/acme4j-utils/src/site/site.xml b/acme4j-utils/src/site/site.xml new file mode 100644 index 00000000..54ddfbf0 --- /dev/null +++ b/acme4j-utils/src/site/site.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + +

+ + + + + + + diff --git a/acme4j-utils/src/test/java/.gitignore b/acme4j-utils/src/test/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-utils/src/test/resources/.gitignore b/acme4j-utils/src/test/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..42e1ee51 --- /dev/null +++ b/pom.xml @@ -0,0 +1,168 @@ + + + + 4.0.0 + + org.shredzone.acme4j + acme4j + 0.1-SNAPSHOT + pom + + acme4j + ACME client for Java + http://acme4j.shredzone.org + 2015 + + + + Apache License Version 2.0 + LICENSE-APL.txt + + + + scm:git:git@github.com:shred/acme4j.git + scm:git:git@github.com:shred/acme4j.git + scm:git:git@github.com:shred/acme4j.git + HEAD + + + GitHub + https://github.com/shred/acme4j/issues + + + + shred + Richard Körber + + + + + 1.52 + 0.4.4 + 1.7.10 + utf-8 + + + + acme4j-client + acme4j-letsencrypt + acme4j-utils + acme4j-example + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.7 + 1.7 + + + + org.apache.maven.plugins + maven-jar-plugin + 2.4 + + + **/.gitignore + + + + + org.apache.maven.plugins + maven-site-plugin + 3.3 + + UTF-8 + + + + org.apache.maven.plugins + maven-release-plugin + 2.5 + + true + v@{project.version} + false + true + + + + + + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 2.7 + + + + dependencies + issue-tracking + license + summary + project-team + scm + + + + + + org.apache.maven.plugins + maven-surefire-report-plugin + 2.17 + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.9.1 + + + http://docs.oracle.com/javase/7/docs/api/ + http://docs.oracle.com/javaee/7/api/ + + UTF-8 + true + + + + + + + junit + junit + [4,) + test + + + org.hamcrest + hamcrest-library + [1.3,) + test + + + org.mockito + mockito-core + [1.9.5,) + test + + + diff --git a/src/site/apt/index.apt b/src/site/apt/index.apt new file mode 100644 index 00000000..2d77586f --- /dev/null +++ b/src/site/apt/index.apt @@ -0,0 +1 @@ + This is a Java ACME client. diff --git a/src/site/resources/css/site.css b/src/site/resources/css/site.css new file mode 100644 index 00000000..992965e2 --- /dev/null +++ b/src/site/resources/css/site.css @@ -0,0 +1,6 @@ +a.externalLink, a.externalLink:link, a.externalLink:visited, a.externalLink:active, a.externalLink:hover { + background: none; + padding-right: 0; +} + + diff --git a/src/site/site.xml b/src/site/site.xml new file mode 100644 index 00000000..e41837bc --- /dev/null +++ b/src/site/site.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + +