Merge master v0.10 into draft

pull/55/head
Richard Körber 2017-04-15 17:37:25 +02:00
commit 698d25fd14
11 changed files with 150 additions and 56 deletions

View File

@ -1,6 +1,6 @@
# ACME Java Client ![build status](https://shredzone.org/badge/draft/acme4j.svg) # ACME Java Client ![build status](https://shredzone.org/badge/draft/acme4j.svg)
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_.

View File

@ -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>

View File

@ -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");

View File

@ -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);
} }
} }

View File

@ -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.
*/ */

View File

@ -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();
}
}
} }

View File

@ -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();
} }

View File

@ -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>

View File

@ -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
View File

@ -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>

View File

@ -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.