mirror of https://github.com/shred/acme4j
Merge master v0.10 into draft
commit
698d25fd14
|
@ -1,6 +1,6 @@
|
||||||
# ACME Java Client 
|
# ACME Java Client 
|
||||||
|
|
||||||
This is a Java client for the [Automatic Certificate Management Environment (ACME)](https://tools.ietf.org/html/draft-ietf-acme-acme-04) protocol.
|
This is a Java client for the [Automatic Certificate Management Environment (ACME)](https://tools.ietf.org/html/draft-ietf-acme-acme-06) protocol.
|
||||||
|
|
||||||
ACME is a protocol that a certificate authority (CA) and an applicant can use to automate the process of verification and certificate issuance.
|
ACME is a protocol that a certificate authority (CA) and an applicant can use to automate the process of verification and certificate issuance.
|
||||||
|
|
||||||
|
@ -40,3 +40,4 @@ _acme4j_ is open source software. The source code is distributed under the terms
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
* I would like to thank Brian Campbell and all the other [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) developers. _acme4j_ would not exist without your excellent work.
|
* I would like to thank Brian Campbell and all the other [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) developers. _acme4j_ would not exist without your excellent work.
|
||||||
|
* I also like to thank everyone who contributed to _acme4j_.
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.shredzone.acme4j</groupId>
|
<groupId>org.shredzone.acme4j</groupId>
|
||||||
<artifactId>acme4j</artifactId>
|
<artifactId>acme4j</artifactId>
|
||||||
<version>0.10-SNAPSHOT</version>
|
<version>0.11-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>acme4j-client</artifactId>
|
<artifactId>acme4j-client</artifactId>
|
||||||
|
|
|
@ -240,7 +240,11 @@ public class Registration extends AcmeResource {
|
||||||
|
|
||||||
X509Certificate cert = null;
|
X509Certificate cert = null;
|
||||||
if (rc == HttpURLConnection.HTTP_CREATED) {
|
if (rc == HttpURLConnection.HTTP_CREATED) {
|
||||||
cert = conn.readCertificate();
|
try {
|
||||||
|
cert = conn.readCertificate();
|
||||||
|
} catch (AcmeProtocolException ex) {
|
||||||
|
LOG.warn("Could not parse attached certificate", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
URI chainCertUri = conn.getLink("up");
|
URI chainCertUri = conn.getLink("up");
|
||||||
|
|
|
@ -17,13 +17,13 @@ import java.net.URI;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.ServiceLoader;
|
import java.util.ServiceLoader;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.stream.StreamSupport;
|
||||||
|
|
||||||
import org.shredzone.acme4j.challenge.Challenge;
|
import org.shredzone.acme4j.challenge.Challenge;
|
||||||
import org.shredzone.acme4j.challenge.TokenChallenge;
|
import org.shredzone.acme4j.challenge.TokenChallenge;
|
||||||
|
@ -40,15 +40,15 @@ import org.shredzone.acme4j.util.JSON;
|
||||||
* volatile data.
|
* volatile data.
|
||||||
*/
|
*/
|
||||||
public class Session {
|
public class Session {
|
||||||
private final Map<Resource, URI> resourceMap = new EnumMap<>(Resource.class);
|
private final AtomicReference<Map<Resource, URI>> resourceMap = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Metadata> metadata = new AtomicReference<>();
|
||||||
private final URI serverUri;
|
private final URI serverUri;
|
||||||
|
private final AcmeProvider provider;
|
||||||
|
|
||||||
private KeyPair keyPair;
|
private KeyPair keyPair;
|
||||||
private URI keyIdentifier;
|
private URI keyIdentifier;
|
||||||
private AcmeProvider provider;
|
|
||||||
private byte[] nonce;
|
private byte[] nonce;
|
||||||
private JSON directoryJson;
|
private JSON directoryJson;
|
||||||
private Metadata metadata;
|
|
||||||
private Locale locale = Locale.getDefault();
|
private Locale locale = Locale.getDefault();
|
||||||
protected Instant directoryCacheExpiry;
|
protected Instant directoryCacheExpiry;
|
||||||
|
|
||||||
|
@ -71,10 +71,25 @@ public class Session {
|
||||||
* {@link URI} of the ACME server
|
* {@link URI} of the ACME server
|
||||||
* @param keyPair
|
* @param keyPair
|
||||||
* {@link KeyPair} of the ACME account
|
* {@link KeyPair} of the ACME account
|
||||||
|
* @throws IllegalArgumentException
|
||||||
|
* if no ACME provider was found for the server URI.
|
||||||
*/
|
*/
|
||||||
public Session(URI serverUri, KeyPair keyPair) {
|
public Session(URI serverUri, KeyPair keyPair) {
|
||||||
this.serverUri = Objects.requireNonNull(serverUri, "serverUri");
|
this.serverUri = Objects.requireNonNull(serverUri, "serverUri");
|
||||||
this.keyPair = Objects.requireNonNull(keyPair, "keyPair");
|
this.keyPair = Objects.requireNonNull(keyPair, "keyPair");
|
||||||
|
|
||||||
|
final URI localServerUri = serverUri;
|
||||||
|
|
||||||
|
Iterable<AcmeProvider> providers = ServiceLoader.load(AcmeProvider.class);
|
||||||
|
provider = StreamSupport.stream(providers.spliterator(), false)
|
||||||
|
.filter(p -> p.accepts(localServerUri))
|
||||||
|
.reduce((a, b) -> {
|
||||||
|
throw new IllegalArgumentException("Both ACME providers "
|
||||||
|
+ a.getClass().getSimpleName() + " and "
|
||||||
|
+ b.getClass().getSimpleName() + " accept "
|
||||||
|
+ localServerUri + ". Please check your classpath.");
|
||||||
|
})
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("No ACME provider found for " + localServerUri));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -143,32 +158,10 @@ public class Session {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the {@link AcmeProvider} that is used for this session.
|
* Returns the {@link AcmeProvider} that is used for this session.
|
||||||
* <p>
|
|
||||||
* The {@link AcmeProvider} instance is lazily created and cached.
|
|
||||||
*
|
*
|
||||||
* @return {@link AcmeProvider}
|
* @return {@link AcmeProvider}
|
||||||
*/
|
*/
|
||||||
public AcmeProvider provider() {
|
public AcmeProvider provider() {
|
||||||
synchronized (this) {
|
|
||||||
if (provider == null) {
|
|
||||||
List<AcmeProvider> candidates = new ArrayList<>();
|
|
||||||
for (AcmeProvider acp : ServiceLoader.load(AcmeProvider.class)) {
|
|
||||||
if (acp.accepts(serverUri)) {
|
|
||||||
candidates.add(acp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("No ACME provider found for " + serverUri);
|
|
||||||
} else if (candidates.size() > 1) {
|
|
||||||
throw new IllegalStateException("There are " + candidates.size() + " "
|
|
||||||
+ AcmeProvider.class.getSimpleName() + " accepting " + serverUri
|
|
||||||
+ ". Please check your classpath.");
|
|
||||||
} else {
|
|
||||||
provider = candidates.get(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,7 +199,7 @@ public class Session {
|
||||||
*/
|
*/
|
||||||
public URI resourceUri(Resource resource) throws AcmeException {
|
public URI resourceUri(Resource resource) throws AcmeException {
|
||||||
readDirectory();
|
readDirectory();
|
||||||
return resourceMap.get(Objects.requireNonNull(resource, "resource"));
|
return resourceMap.get().get(Objects.requireNonNull(resource, "resource"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -217,7 +210,7 @@ public class Session {
|
||||||
*/
|
*/
|
||||||
public Metadata getMetadata() throws AcmeException {
|
public Metadata getMetadata() throws AcmeException {
|
||||||
readDirectory();
|
readDirectory();
|
||||||
return metadata;
|
return metadata.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -227,26 +220,28 @@ public class Session {
|
||||||
private void readDirectory() throws AcmeException {
|
private void readDirectory() throws AcmeException {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
if (directoryJson == null || !directoryCacheExpiry.isAfter(now)) {
|
if (directoryJson != null && directoryCacheExpiry.isAfter(now)) {
|
||||||
directoryJson = provider().directory(this, getServerUri());
|
return;
|
||||||
directoryCacheExpiry = now.plus(Duration.ofHours(1));
|
}
|
||||||
|
directoryJson = provider().directory(this, getServerUri());
|
||||||
|
directoryCacheExpiry = now.plus(Duration.ofHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
JSON meta = directoryJson.get("meta").asObject();
|
JSON meta = directoryJson.get("meta").asObject();
|
||||||
if (meta != null) {
|
if (meta != null) {
|
||||||
metadata = new Metadata(meta);
|
metadata.set(new Metadata(meta));
|
||||||
} else {
|
} else {
|
||||||
metadata = new Metadata(JSON.empty());
|
metadata.set(new Metadata(JSON.empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceMap.clear();
|
Map<Resource, URI> map = new EnumMap<>(Resource.class);
|
||||||
for (Resource res : Resource.values()) {
|
for (Resource res : Resource.values()) {
|
||||||
URI uri = directoryJson.get(res.path()).asURI();
|
URI uri = directoryJson.get(res.path()).asURI();
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
resourceMap.put(res, uri);
|
map.put(res, uri);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resourceMap.set(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import org.shredzone.acme4j.challenge.Dns01Challenge;
|
||||||
import org.shredzone.acme4j.challenge.Http01Challenge;
|
import org.shredzone.acme4j.challenge.Http01Challenge;
|
||||||
import org.shredzone.acme4j.connector.Resource;
|
import org.shredzone.acme4j.connector.Resource;
|
||||||
import org.shredzone.acme4j.exception.AcmeException;
|
import org.shredzone.acme4j.exception.AcmeException;
|
||||||
|
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||||
import org.shredzone.acme4j.provider.AcmeProvider;
|
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||||
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
||||||
import org.shredzone.acme4j.util.JSON;
|
import org.shredzone.acme4j.util.JSON;
|
||||||
|
@ -399,6 +400,62 @@ public class RegistrationTest {
|
||||||
provider.close();
|
provider.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that an unparseable certificate can be requested, and at least its location
|
||||||
|
* is made available.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testRequestCertificateBrokenSync() throws AcmeException, IOException {
|
||||||
|
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||||
|
@Override
|
||||||
|
public void sendSignedRequest(URI uri, JSONBuilder claims, Session session) {
|
||||||
|
assertThat(uri, is(resourceUri));
|
||||||
|
assertThat(claims.toString(), sameJSONAs(getJson("requestCertificateRequestWithDate")));
|
||||||
|
assertThat(session, is(notNullValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int accept(int... httpStatus) throws AcmeException {
|
||||||
|
assertThat(httpStatus, isIntArrayContainingInAnyOrder(
|
||||||
|
HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_ACCEPTED));
|
||||||
|
return HttpURLConnection.HTTP_CREATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public X509Certificate readCertificate() {
|
||||||
|
throw new AcmeProtocolException("Failed to read certificate");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public URI getLink(String relation) {
|
||||||
|
switch(relation) {
|
||||||
|
case "up": return chainUri;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public URI getLocation() {
|
||||||
|
return locationUri;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
provider.putTestResource(Resource.NEW_CERT, resourceUri);
|
||||||
|
|
||||||
|
byte[] csr = TestUtils.getResourceAsByteArray("/csr.der");
|
||||||
|
ZoneId utc = ZoneId.of("UTC");
|
||||||
|
Instant notBefore = LocalDate.of(2016, 1, 1).atStartOfDay(utc).toInstant();
|
||||||
|
Instant notAfter = LocalDate.of(2016, 1, 8).atStartOfDay(utc).toInstant();
|
||||||
|
|
||||||
|
Registration registration = new Registration(provider.createSession(), locationUri);
|
||||||
|
Certificate cert = registration.requestCertificate(csr, notBefore, notAfter);
|
||||||
|
|
||||||
|
assertThat(cert.getLocation(), is(locationUri));
|
||||||
|
assertThat(cert.getChainLocation(), is(chainUri));
|
||||||
|
|
||||||
|
provider.close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test that the account key can be changed.
|
* Test that the account key can be changed.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -775,4 +775,22 @@ public class DefaultConnectionTest {
|
||||||
verifyNoMoreInteractions(mockUrlConnection);
|
verifyNoMoreInteractions(mockUrlConnection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that a bad certificate throws an exception.
|
||||||
|
*/
|
||||||
|
@Test(expected = AcmeProtocolException.class)
|
||||||
|
public void testReadBadCertificate() throws Exception {
|
||||||
|
X509Certificate original = TestUtils.createCertificate();
|
||||||
|
byte[] badCert = original.getEncoded();
|
||||||
|
Arrays.sort(badCert); // break it
|
||||||
|
|
||||||
|
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/pkix-cert");
|
||||||
|
when(mockUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream(badCert));
|
||||||
|
|
||||||
|
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) {
|
||||||
|
conn.conn = mockUrlConnection;
|
||||||
|
conn.readCertificate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,9 +71,9 @@ public class SessionProviderTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are two testing providers accepting {@code acme://example.net}. Test that
|
* There are two testing providers accepting {@code acme://example.net}. Test that
|
||||||
* connecting to this URI will result in an {@link IllegalStateException}.
|
* connecting to this URI will result in an {@link IllegalArgumentException}.
|
||||||
*/
|
*/
|
||||||
@Test(expected = IllegalStateException.class)
|
@Test(expected = IllegalArgumentException.class)
|
||||||
public void testDuplicate() throws Exception {
|
public void testDuplicate() throws Exception {
|
||||||
new Session(new URI("acme://example.net"), keyPair).provider();
|
new Session(new URI("acme://example.net"), keyPair).provider();
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.shredzone.acme4j</groupId>
|
<groupId>org.shredzone.acme4j</groupId>
|
||||||
<artifactId>acme4j</artifactId>
|
<artifactId>acme4j</artifactId>
|
||||||
<version>0.10-SNAPSHOT</version>
|
<version>0.11-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>acme4j-example</artifactId>
|
<artifactId>acme4j-example</artifactId>
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.shredzone.acme4j</groupId>
|
<groupId>org.shredzone.acme4j</groupId>
|
||||||
<artifactId>acme4j</artifactId>
|
<artifactId>acme4j</artifactId>
|
||||||
<version>0.10-SNAPSHOT</version>
|
<version>0.11-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>acme4j-utils</artifactId>
|
<artifactId>acme4j-utils</artifactId>
|
||||||
|
|
21
pom.xml
21
pom.xml
|
@ -19,7 +19,7 @@
|
||||||
|
|
||||||
<groupId>org.shredzone.acme4j</groupId>
|
<groupId>org.shredzone.acme4j</groupId>
|
||||||
<artifactId>acme4j</artifactId>
|
<artifactId>acme4j</artifactId>
|
||||||
<version>0.10-SNAPSHOT</version>
|
<version>0.11-SNAPSHOT</version>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
<name>acme4j</name>
|
<name>acme4j</name>
|
||||||
|
@ -51,9 +51,9 @@
|
||||||
</developers>
|
</developers>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<bouncycastle.version>1.55</bouncycastle.version>
|
<bouncycastle.version>1.56</bouncycastle.version>
|
||||||
<jose4j.version>0.5.3</jose4j.version>
|
<jose4j.version>0.5.5</jose4j.version>
|
||||||
<slf4j.version>1.7.21</slf4j.version>
|
<slf4j.version>1.7.25</slf4j.version>
|
||||||
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
|
||||||
<skipITs>true</skipITs>
|
<skipITs>true</skipITs>
|
||||||
</properties>
|
</properties>
|
||||||
|
@ -134,6 +134,19 @@
|
||||||
<localCheckout>true</localCheckout>
|
<localCheckout>true</localCheckout>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-source-plugin</artifactId>
|
||||||
|
<version>3.0.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>attach-sources</id>
|
||||||
|
<goals>
|
||||||
|
<goal>jar</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
<reporting>
|
<reporting>
|
||||||
|
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
This document will help you migrate your code to the latest _acme4j_ version.
|
This document will help you migrate your code to the latest _acme4j_ version.
|
||||||
|
|
||||||
|
## Migration to Version 0.10
|
||||||
|
|
||||||
|
Starting with version 0.10, _acme4j_ requires Java 8 or higher. This is also reflected in the API.
|
||||||
|
|
||||||
|
The most noticeable change is that the old `java.util.Date` has been replaced by the new `java.time` API in the entire project. If you don't want to migrate your code to the new API, you can use `Date.from()` and `Date.toInstant()` to convert between the different date objects, where necessary.
|
||||||
|
|
||||||
## Migration to Version 0.9
|
## Migration to Version 0.9
|
||||||
|
|
||||||
Version 0.9 brought many changes to the internal API. However, this is only relevant if you run your own CA and make own extensions to _acme4j_ (e.g. if you implement a proprietary `Challenge`). If you use _acme4j_ only for retrieving certificates, you should not notice any changes.
|
Version 0.9 brought many changes to the internal API. However, this is only relevant if you run your own CA and make own extensions to _acme4j_ (e.g. if you implement a proprietary `Challenge`). If you use _acme4j_ only for retrieving certificates, you should not notice any changes.
|
||||||
|
|
Loading…
Reference in New Issue