diff --git a/README.md b/README.md index 679a20f1..c65de9b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -40,3 +40,4 @@ _acme4j_ is open source software. The source code is distributed under the terms ## 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 also like to thank everyone who contributed to _acme4j_. diff --git a/acme4j-client/pom.xml b/acme4j-client/pom.xml index 00ae0ac9..750e85ae 100644 --- a/acme4j-client/pom.xml +++ b/acme4j-client/pom.xml @@ -20,7 +20,7 @@ org.shredzone.acme4j acme4j - 0.10-SNAPSHOT + 0.11-SNAPSHOT acme4j-client diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java index 39a89c98..e6ad3342 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java @@ -240,7 +240,11 @@ public class Registration extends AcmeResource { X509Certificate cert = null; 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"); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java index 9a177a06..54d3a10a 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Session.java @@ -17,13 +17,13 @@ import java.net.URI; import java.security.KeyPair; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; import java.util.EnumMap; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; 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.TokenChallenge; @@ -40,15 +40,15 @@ import org.shredzone.acme4j.util.JSON; * volatile data. */ public class Session { - private final Map resourceMap = new EnumMap<>(Resource.class); + private final AtomicReference> resourceMap = new AtomicReference<>(); + private final AtomicReference metadata = new AtomicReference<>(); private final URI serverUri; + private final AcmeProvider provider; private KeyPair keyPair; private URI keyIdentifier; - private AcmeProvider provider; private byte[] nonce; private JSON directoryJson; - private Metadata metadata; private Locale locale = Locale.getDefault(); protected Instant directoryCacheExpiry; @@ -71,10 +71,25 @@ public class Session { * {@link URI} of the ACME server * @param keyPair * {@link KeyPair} of the ACME account + * @throws IllegalArgumentException + * if no ACME provider was found for the server URI. */ public Session(URI serverUri, KeyPair keyPair) { this.serverUri = Objects.requireNonNull(serverUri, "serverUri"); this.keyPair = Objects.requireNonNull(keyPair, "keyPair"); + + final URI localServerUri = serverUri; + + Iterable 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. - *

- * The {@link AcmeProvider} instance is lazily created and cached. * * @return {@link AcmeProvider} */ public AcmeProvider provider() { - synchronized (this) { - if (provider == null) { - List 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; } @@ -206,7 +199,7 @@ public class Session { */ public URI resourceUri(Resource resource) throws AcmeException { 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 { readDirectory(); - return metadata; + return metadata.get(); } /** @@ -227,26 +220,28 @@ public class Session { private void readDirectory() throws AcmeException { synchronized (this) { Instant now = Instant.now(); - if (directoryJson == null || !directoryCacheExpiry.isAfter(now)) { - directoryJson = provider().directory(this, getServerUri()); - directoryCacheExpiry = now.plus(Duration.ofHours(1)); + if (directoryJson != null && directoryCacheExpiry.isAfter(now)) { + return; + } + directoryJson = provider().directory(this, getServerUri()); + directoryCacheExpiry = now.plus(Duration.ofHours(1)); + } - JSON meta = directoryJson.get("meta").asObject(); - if (meta != null) { - metadata = new Metadata(meta); - } else { - metadata = new Metadata(JSON.empty()); - } + JSON meta = directoryJson.get("meta").asObject(); + if (meta != null) { + metadata.set(new Metadata(meta)); + } else { + metadata.set(new Metadata(JSON.empty())); + } - resourceMap.clear(); - for (Resource res : Resource.values()) { - URI uri = directoryJson.get(res.path()).asURI(); - if (uri != null) { - resourceMap.put(res, uri); - } - } + Map map = new EnumMap<>(Resource.class); + for (Resource res : Resource.values()) { + URI uri = directoryJson.get(res.path()).asURI(); + if (uri != null) { + map.put(res, uri); } } + resourceMap.set(map); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java index 1c4c689f..11effc4b 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java @@ -39,6 +39,7 @@ import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.connector.Resource; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.provider.AcmeProvider; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.util.JSON; @@ -399,6 +400,62 @@ public class RegistrationTest { 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. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index cc9d892d..2981a4bd 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -775,4 +775,22 @@ public class DefaultConnectionTest { 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(); + } + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java index 73a8e2ec..ae66b5d2 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/SessionProviderTest.java @@ -71,9 +71,9 @@ public class SessionProviderTest { /** * 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 { new Session(new URI("acme://example.net"), keyPair).provider(); } diff --git a/acme4j-example/pom.xml b/acme4j-example/pom.xml index 21b95136..e86e0f0e 100644 --- a/acme4j-example/pom.xml +++ b/acme4j-example/pom.xml @@ -20,7 +20,7 @@ org.shredzone.acme4j acme4j - 0.10-SNAPSHOT + 0.11-SNAPSHOT acme4j-example diff --git a/acme4j-utils/pom.xml b/acme4j-utils/pom.xml index 41bba567..7dea129c 100644 --- a/acme4j-utils/pom.xml +++ b/acme4j-utils/pom.xml @@ -20,7 +20,7 @@ org.shredzone.acme4j acme4j - 0.10-SNAPSHOT + 0.11-SNAPSHOT acme4j-utils diff --git a/pom.xml b/pom.xml index 59e099bb..dd65fb53 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ org.shredzone.acme4j acme4j - 0.10-SNAPSHOT + 0.11-SNAPSHOT pom acme4j @@ -51,9 +51,9 @@ - 1.55 - 0.5.3 - 1.7.21 + 1.56 + 0.5.5 + 1.7.25 utf-8 true @@ -134,6 +134,19 @@ true + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + + jar + + + + diff --git a/src/site/markdown/migration.md b/src/site/markdown/migration.md index 30dd0fab..ea06ee79 100644 --- a/src/site/markdown/migration.md +++ b/src/site/markdown/migration.md @@ -2,6 +2,12 @@ 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 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.