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.javanatureorg.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 
-*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