mirror of https://github.com/shred/acme4j
Change Certificate resource
parent
3951577708
commit
846e200e62
|
@ -13,6 +13,10 @@
|
|||
*/
|
||||
package org.shredzone.acme4j;
|
||||
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
|
@ -24,7 +28,7 @@ import org.shredzone.acme4j.connector.Connection;
|
|||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.util.AcmeUtils;
|
||||
import org.shredzone.acme4j.util.JSONBuilder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
@ -35,24 +39,14 @@ import org.slf4j.LoggerFactory;
|
|||
public class Certificate extends AcmeResource {
|
||||
private static final long serialVersionUID = 7381527770159084201L;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
|
||||
private static final int MAX_CHAIN_LENGTH = 10;
|
||||
|
||||
private URL chainCertUrl;
|
||||
private X509Certificate cert = null;
|
||||
private X509Certificate[] chain = null;
|
||||
private ArrayList<X509Certificate> certChain = null;
|
||||
|
||||
protected Certificate(Session session, URL certUrl) {
|
||||
super(session);
|
||||
setLocation(certUrl);
|
||||
}
|
||||
|
||||
protected Certificate(Session session, URL certUrl, URL chainUrl, X509Certificate cert) {
|
||||
super(session);
|
||||
setLocation(certUrl);
|
||||
this.chainCertUrl = chainUrl;
|
||||
this.cert = cert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of {@link Certificate} and binds it to the {@link Session}.
|
||||
*
|
||||
|
@ -67,79 +61,65 @@ public class Certificate extends AcmeResource {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the URL of the certificate chain. {@code null} if not known or not
|
||||
* available.
|
||||
*/
|
||||
public URL getChainLocation() {
|
||||
return chainCertUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the certificate. The result is cached.
|
||||
* Downloads the certificate chain.
|
||||
*
|
||||
* @return {@link X509Certificate} that was downloaded
|
||||
* @throws AcmeRetryAfterException
|
||||
* the certificate is still being created, and the server returned an
|
||||
* estimated date when it will be ready for download. You should wait for
|
||||
* the date given in {@link AcmeRetryAfterException#getRetryAfter()}
|
||||
* before trying again.
|
||||
* @throws AcmeException
|
||||
* if the certificate could not be downloaded
|
||||
*/
|
||||
public X509Certificate download() throws AcmeException {
|
||||
if (cert == null) {
|
||||
public void download() throws AcmeException {
|
||||
if (certChain == null) {
|
||||
LOG.debug("download");
|
||||
try (Connection conn = getSession().provider().connect()) {
|
||||
conn.sendRequest(getLocation(), getSession());
|
||||
conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED);
|
||||
conn.handleRetryAfter("certificate is not available for download yet");
|
||||
|
||||
chainCertUrl = conn.getLink("up");
|
||||
cert = conn.readCertificate();
|
||||
conn.accept(HttpURLConnection.HTTP_OK);
|
||||
certChain = new ArrayList<>(conn.readCertificates());
|
||||
}
|
||||
}
|
||||
return cert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the certificate chain. The result is cached.
|
||||
* Returns the created certificate.
|
||||
*
|
||||
* @return Chain of {@link X509Certificate}s
|
||||
* @throws AcmeRetryAfterException
|
||||
* the certificate is still being created, and the server returned an
|
||||
* estimated date when it will be ready for download. You should wait for
|
||||
* the date given in {@link AcmeRetryAfterException#getRetryAfter()}
|
||||
* before trying again.
|
||||
* @return The created end-entity {@link X509Certificate} without issuer chain.
|
||||
* @throws AcmeProtocolException
|
||||
* if lazy downloading failed
|
||||
*/
|
||||
public X509Certificate[] downloadChain() throws AcmeException {
|
||||
if (chain == null) {
|
||||
if (chainCertUrl == null) {
|
||||
download();
|
||||
public X509Certificate getCertificate() {
|
||||
lazyDownload();
|
||||
return certChain.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the created certificate and issuer chain.
|
||||
*
|
||||
* @return The created end-entity {@link X509Certificate} and issuer chain. The first
|
||||
* certificate is always the end-entity certificate, followed by the
|
||||
* intermediate certificates required to build a path to a trusted root.
|
||||
* @throws AcmeProtocolException
|
||||
* if lazy downloading failed
|
||||
*/
|
||||
public List<X509Certificate> getCertificateChain() {
|
||||
lazyDownload();
|
||||
return unmodifiableList(certChain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the certificate to the given writer. It is written in PEM format, with the
|
||||
* end-entity cert coming first, followed by the intermediate ceritificates.
|
||||
*
|
||||
* @param out
|
||||
* {@link Writer} to write to. The writer is not closed after use.
|
||||
* @throws AcmeProtocolException
|
||||
* if lazy downloading failed
|
||||
*/
|
||||
public void writeCertificate(Writer out) throws IOException {
|
||||
try {
|
||||
for (X509Certificate cert : getCertificateChain()) {
|
||||
AcmeUtils.writeToPem(cert.getEncoded(), "CERTIFICATE", out);
|
||||
}
|
||||
|
||||
if (chainCertUrl == null) {
|
||||
throw new AcmeProtocolException("No certificate chain provided");
|
||||
}
|
||||
|
||||
LOG.debug("downloadChain");
|
||||
|
||||
List<X509Certificate> certChain = new ArrayList<>();
|
||||
URL link = chainCertUrl;
|
||||
while (link != null && certChain.size() < MAX_CHAIN_LENGTH) {
|
||||
try (Connection conn = getSession().provider().connect()) {
|
||||
conn.sendRequest(chainCertUrl, getSession());
|
||||
conn.accept(HttpURLConnection.HTTP_OK);
|
||||
|
||||
certChain.add(conn.readCertificate());
|
||||
link = conn.getLink("up");
|
||||
}
|
||||
}
|
||||
if (link != null) {
|
||||
throw new AcmeProtocolException("Recursion limit reached (" + MAX_CHAIN_LENGTH
|
||||
+ "). Didn't get " + link);
|
||||
}
|
||||
|
||||
chain = certChain.toArray(new X509Certificate[certChain.size()]);
|
||||
} catch (CertificateEncodingException ex) {
|
||||
throw new IOException("Encoding error", ex);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -164,14 +144,10 @@ public class Certificate extends AcmeResource {
|
|||
throw new AcmeProtocolException("CA does not support certificate revocation");
|
||||
}
|
||||
|
||||
if (cert == null) {
|
||||
download();
|
||||
}
|
||||
|
||||
try (Connection conn = getSession().provider().connect()) {
|
||||
JSONBuilder claims = new JSONBuilder();
|
||||
claims.putResource(Resource.REVOKE_CERT);
|
||||
claims.putBase64("certificate", cert.getEncoded());
|
||||
claims.putBase64("certificate", getCertificate().getEncoded());
|
||||
if (reason != null) {
|
||||
claims.put("reason", reason.getReasonCode());
|
||||
}
|
||||
|
@ -183,4 +159,16 @@ public class Certificate extends AcmeResource {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily downloads the certificate. Throws a runtime {@link AcmeProtocolException} if
|
||||
* the download failed.
|
||||
*/
|
||||
private void lazyDownload() {
|
||||
try {
|
||||
download();
|
||||
} catch (AcmeException ex) {
|
||||
throw new AcmeProtocolException("Could not lazily download certificate", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ public class Order extends AcmeResource {
|
|||
private Instant notBefore;
|
||||
private Instant notAfter;
|
||||
private List<URL> authorizations;
|
||||
private URL certificate;
|
||||
private Certificate certificate;
|
||||
private boolean loaded = false;
|
||||
|
||||
protected Order(Session session, URL location) {
|
||||
|
@ -113,10 +113,9 @@ public class Order extends AcmeResource {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the {@link URL} where the certificate can be downloaded from, if it is
|
||||
* available. {@code null} otherwise.
|
||||
* Gets the {@link Certificate} if it is available. {@code null} otherwise.
|
||||
*/
|
||||
public URL getCertificateLocation() {
|
||||
public Certificate getCertificate() {
|
||||
load();
|
||||
return certificate;
|
||||
}
|
||||
|
@ -160,7 +159,10 @@ public class Order extends AcmeResource {
|
|||
this.csr = json.get("csr").asBinary();
|
||||
this.notBefore = json.get("notBefore").asInstant();
|
||||
this.notAfter = json.get("notAfter").asInstant();
|
||||
this.certificate = json.get("certificate").asURL();
|
||||
|
||||
URL certUrl = json.get("certificate").asURL();
|
||||
certificate = certUrl != null ? Certificate.bind(getSession(), certUrl) : null;
|
||||
|
||||
this.authorizations = json.get("authorizations").asArray().stream()
|
||||
.map(JSON.Value::asURL)
|
||||
.collect(toList());
|
||||
|
|
|
@ -19,7 +19,6 @@ import java.net.HttpURLConnection;
|
|||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
|
@ -228,68 +227,6 @@ public class Registration extends AcmeResource {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a certificate for the given CSR.
|
||||
* <p>
|
||||
* All domains given in the CSR must be authorized before.
|
||||
*
|
||||
* @param csr
|
||||
* PKCS#10 Certificate Signing Request to be sent to the server
|
||||
* @return The {@link Certificate}
|
||||
*/
|
||||
public Certificate requestCertificate(byte[] csr) throws AcmeException {
|
||||
return requestCertificate(csr, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a certificate for the given CSR.
|
||||
* <p>
|
||||
* All domains given in the CSR must be authorized before.
|
||||
*
|
||||
* @param csr
|
||||
* PKCS#10 Certificate Signing Request to be sent to the server
|
||||
* @param notBefore
|
||||
* requested value of the notBefore field in the certificate, {@code null}
|
||||
* for default. May be ignored by the server.
|
||||
* @param notAfter
|
||||
* requested value of the notAfter field in the certificate, {@code null}
|
||||
* for default. May be ignored by the server.
|
||||
* @return The {@link Certificate}
|
||||
*/
|
||||
public Certificate requestCertificate(byte[] csr, Instant notBefore, Instant notAfter)
|
||||
throws AcmeException {
|
||||
Objects.requireNonNull(csr, "csr");
|
||||
|
||||
LOG.debug("requestCertificate");
|
||||
try (Connection conn = getSession().provider().connect()) {
|
||||
JSONBuilder claims = new JSONBuilder();
|
||||
claims.putResource(Resource.NEW_CERT);
|
||||
claims.putBase64("csr", csr);
|
||||
if (notBefore != null) {
|
||||
claims.put("notBefore", notBefore);
|
||||
}
|
||||
if (notAfter != null) {
|
||||
claims.put("notAfter", notAfter);
|
||||
}
|
||||
|
||||
conn.sendSignedRequest(getSession().resourceUrl(Resource.NEW_CERT), claims, getSession());
|
||||
int rc = conn.accept(HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_ACCEPTED);
|
||||
|
||||
X509Certificate cert = null;
|
||||
if (rc == HttpURLConnection.HTTP_CREATED) {
|
||||
try {
|
||||
cert = conn.readCertificate();
|
||||
} catch (AcmeProtocolException ex) {
|
||||
LOG.warn("Could not parse attached certificate", ex);
|
||||
}
|
||||
}
|
||||
|
||||
URL chainCertUrl = conn.getLink("up");
|
||||
|
||||
return new Certificate(getSession(), conn.getLocation(), chainCertUrl, cert);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the {@link KeyPair} associated with the registration.
|
||||
* <p>
|
||||
|
|
|
@ -17,6 +17,7 @@ import java.net.URI;
|
|||
import java.net.URL;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
|
@ -91,11 +92,11 @@ public interface Connection extends AutoCloseable {
|
|||
JSON readJsonResponse() throws AcmeException;
|
||||
|
||||
/**
|
||||
* Reads a certificate.
|
||||
* Reads a certificate and its issuers.
|
||||
*
|
||||
* @return {@link X509Certificate} that was read.
|
||||
* @return List of X.509 certificate and chain that was read.
|
||||
*/
|
||||
X509Certificate readCertificate() throws AcmeException;
|
||||
List<X509Certificate> readCertificates() throws AcmeException;
|
||||
|
||||
/**
|
||||
* Throws an {@link AcmeRetryAfterException} if the last status was HTTP Accepted and
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.connector;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static org.shredzone.acme4j.util.AcmeUtils.keyAlgorithm;
|
||||
|
||||
import java.io.IOException;
|
||||
|
@ -272,17 +273,19 @@ public class DefaultConnection implements Connection {
|
|||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate readCertificate() throws AcmeException {
|
||||
public List<X509Certificate> readCertificates() throws AcmeException {
|
||||
assertConnectionIsOpen();
|
||||
|
||||
String contentType = conn.getHeaderField(CONTENT_TYPE_HEADER);
|
||||
if (!("application/pkix-cert".equals(contentType))) {
|
||||
if (!("application/pem-certificate-chain".equals(contentType))) {
|
||||
throw new AcmeProtocolException("Unexpected content type: " + contentType);
|
||||
}
|
||||
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate) cf.generateCertificate(in);
|
||||
return cf.generateCertificates(in).stream()
|
||||
.map(c -> (X509Certificate) c)
|
||||
.collect(toList());
|
||||
} catch (IOException ex) {
|
||||
throw new AcmeNetworkException(ex);
|
||||
} catch (CertificateException ex) {
|
||||
|
|
|
@ -13,13 +13,16 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.io.Writer;
|
||||
import java.net.IDN;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
@ -50,6 +53,9 @@ public final class AcmeUtils {
|
|||
private static final Pattern TZ_PATTERN = Pattern.compile(
|
||||
"([+-])(\\d{2}):?(\\d{2})$");
|
||||
|
||||
private static final Base64.Encoder PEM_ENCODER = Base64.getMimeEncoder(64, "\n".getBytes());
|
||||
|
||||
|
||||
private AcmeUtils() {
|
||||
// Utility class without constructor
|
||||
}
|
||||
|
@ -229,4 +235,20 @@ public final class AcmeUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an encoded key or certificate to a file in PEM format.
|
||||
*
|
||||
* @param encoded
|
||||
* Encoded data to write
|
||||
* @param label
|
||||
* PEM label, e.g. "CERTIFICATE"
|
||||
* @param out
|
||||
* {@link Writer} to write to. It will not be closed after use!
|
||||
*/
|
||||
public static void writeToPem(byte[] encoded, String label, Writer out) throws IOException {
|
||||
out.append("-----BEGIN ").append(label).append("-----\n");
|
||||
out.append(new String(PEM_ENCODER.encode(encoded)));
|
||||
out.append("\n-----END ").append(label).append("-----\n");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,21 +14,22 @@
|
|||
package org.shredzone.acme4j;
|
||||
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.junit.Assert.assertThat;
|
||||
import static org.shredzone.acme4j.util.TestUtils.*;
|
||||
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.shredzone.acme4j.connector.Resource;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.provider.TestableConnectionProvider;
|
||||
import org.shredzone.acme4j.util.JSONBuilder;
|
||||
import org.shredzone.acme4j.util.TestUtils;
|
||||
|
@ -40,105 +41,64 @@ public class CertificateTest {
|
|||
|
||||
private URL resourceUrl = url("http://example.com/acme/resource");
|
||||
private URL locationUrl = url("http://example.com/acme/certificate");
|
||||
private URL chainUrl = url("http://example.com/acme/chain");
|
||||
|
||||
/**
|
||||
* Test that a certificate can be downloaded.
|
||||
*/
|
||||
@Test
|
||||
public void testDownload() throws AcmeException, IOException {
|
||||
final X509Certificate originalCert = TestUtils.createCertificate();
|
||||
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
private boolean isLocationUrl;
|
||||
|
||||
@Override
|
||||
public void sendRequest(URL url, Session session) {
|
||||
assertThat(url, isOneOf(locationUrl, chainUrl));
|
||||
isLocationUrl = url.equals(locationUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int accept(int... httpStatus) throws AcmeException {
|
||||
if (isLocationUrl) {
|
||||
// The leaf certificate, might be asynchronous
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(
|
||||
HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED));
|
||||
} else {
|
||||
// The root certificate chain, always OK
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(
|
||||
HttpURLConnection.HTTP_OK));
|
||||
}
|
||||
return HttpURLConnection.HTTP_OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate readCertificate() {
|
||||
return originalCert;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
// Just do nothing
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLink(String relation) {
|
||||
switch(relation) {
|
||||
case "up": return (isLocationUrl ? chainUrl : null);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl);
|
||||
X509Certificate downloadedCert = cert.download();
|
||||
assertThat(downloadedCert, is(sameInstance(originalCert)));
|
||||
assertThat(cert.getChainLocation(), is(chainUrl));
|
||||
|
||||
X509Certificate[] downloadedChain = cert.downloadChain();
|
||||
assertThat(downloadedChain.length, is(1));
|
||||
assertThat(downloadedChain[0], is(sameInstance(originalCert)));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a {@link AcmeRetryAfterException} is thrown.
|
||||
*/
|
||||
@Test
|
||||
public void testRetryAfter() throws AcmeException, IOException {
|
||||
final Instant retryAfter = Instant.now().plus(Duration.ofSeconds(30));
|
||||
public void testDownload() throws Exception {
|
||||
final List<X509Certificate> originalCert = TestUtils.createCertificate();
|
||||
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public void sendRequest(URL url, Session session) {
|
||||
assertThat(url, is(locationUrl));
|
||||
assertThat(session, is(notNullValue()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int accept(int... httpStatus) throws AcmeException {
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(
|
||||
HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED));
|
||||
return HttpURLConnection.HTTP_ACCEPTED;
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_OK));
|
||||
return HttpURLConnection.HTTP_OK;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void handleRetryAfter(String message) throws AcmeException {
|
||||
throw new AcmeRetryAfterException(message, retryAfter);
|
||||
public List<X509Certificate> readCertificates() throws AcmeException {
|
||||
return originalCert;
|
||||
}
|
||||
};
|
||||
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl);
|
||||
cert.download();
|
||||
|
||||
try {
|
||||
cert.download();
|
||||
fail("Expected AcmeRetryAfterException");
|
||||
} catch (AcmeRetryAfterException ex) {
|
||||
assertThat(ex.getRetryAfter(), is(retryAfter));
|
||||
X509Certificate downloadedCert = cert.getCertificate();
|
||||
assertThat(downloadedCert.getEncoded(), is(originalCert.get(0).getEncoded()));
|
||||
|
||||
List<X509Certificate> downloadedChain = cert.getCertificateChain();
|
||||
assertThat(downloadedChain.size(), is(originalCert.size()));
|
||||
for (int ix = 0; ix < downloadedChain.size(); ix++) {
|
||||
assertThat(downloadedChain.get(ix).getEncoded(), is(originalCert.get(ix).getEncoded()));
|
||||
}
|
||||
|
||||
byte[] writtenPem;
|
||||
byte[] originalPem;
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
OutputStreamWriter w = new OutputStreamWriter(baos)) {
|
||||
cert.writeCertificate(w);
|
||||
w.flush();
|
||||
writtenPem = baos.toByteArray();
|
||||
}
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
InputStream in = getClass().getResourceAsStream("/cert.pem")) {
|
||||
int len;
|
||||
byte[] buffer = new byte[2048];
|
||||
while((len = in.read(buffer)) >= 0) {
|
||||
baos.write(buffer, 0, len);
|
||||
}
|
||||
originalPem = baos.toByteArray();
|
||||
}
|
||||
assertThat(writtenPem, is(originalPem));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
|
@ -147,14 +107,24 @@ public class CertificateTest {
|
|||
*/
|
||||
@Test
|
||||
public void testRevokeCertificate() throws AcmeException, IOException {
|
||||
final X509Certificate originalCert = TestUtils.createCertificate();
|
||||
final List<X509Certificate> originalCert = TestUtils.createCertificate();
|
||||
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
private boolean certRequested = false;
|
||||
|
||||
@Override
|
||||
public void sendRequest(URL url, Session session) {
|
||||
assertThat(url, is(locationUrl));
|
||||
assertThat(session, is(notNullValue()));
|
||||
certRequested = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSignedRequest(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("revokeCertificateRequest").toString()));
|
||||
assertThat(session, is(notNullValue()));
|
||||
certRequested = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -162,11 +132,17 @@ public class CertificateTest {
|
|||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_OK));
|
||||
return HttpURLConnection.HTTP_OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<X509Certificate> readCertificates() throws AcmeException {
|
||||
assertThat(certRequested, is(true));
|
||||
return originalCert;
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
|
||||
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl, null, originalCert);
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl);
|
||||
cert.revoke();
|
||||
|
||||
provider.close();
|
||||
|
@ -177,14 +153,24 @@ public class CertificateTest {
|
|||
*/
|
||||
@Test
|
||||
public void testRevokeCertificateWithReason() throws AcmeException, IOException {
|
||||
final X509Certificate originalCert = TestUtils.createCertificate();
|
||||
final List<X509Certificate> originalCert = TestUtils.createCertificate();
|
||||
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
private boolean certRequested = false;
|
||||
|
||||
@Override
|
||||
public void sendRequest(URL url, Session session) {
|
||||
assertThat(url, is(locationUrl));
|
||||
assertThat(session, is(notNullValue()));
|
||||
certRequested = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSignedRequest(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("revokeCertificateWithReasonRequest").toString()));
|
||||
assertThat(session, is(notNullValue()));
|
||||
certRequested = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -192,11 +178,17 @@ public class CertificateTest {
|
|||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_OK));
|
||||
return HttpURLConnection.HTTP_OK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<X509Certificate> readCertificates() throws AcmeException {
|
||||
assertThat(certRequested, is(true));
|
||||
return originalCert;
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
|
||||
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl, null, originalCert);
|
||||
Certificate cert = new Certificate(provider.createSession(), locationUrl);
|
||||
cert.revoke(RevocationReason.KEY_COMPROMISE);
|
||||
|
||||
provider.close();
|
||||
|
|
|
@ -72,7 +72,7 @@ public class OrderTest {
|
|||
|
||||
assertThat(order.getNotBefore(), is(parseTimestamp("2016-01-01T00:00:00Z")));
|
||||
assertThat(order.getNotAfter(), is(parseTimestamp("2016-01-08T00:00:00Z")));
|
||||
assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(order.getCertificate().getLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(order.getCsr(), is(csr));
|
||||
|
||||
List<Authorization> auths = order.getAuthorizations();
|
||||
|
@ -117,12 +117,12 @@ public class OrderTest {
|
|||
|
||||
// Lazy loading
|
||||
assertThat(requestWasSent.get(), is(false));
|
||||
assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(order.getCertificate().getLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(requestWasSent.get(), is(true));
|
||||
|
||||
// Subsequent queries do not trigger another load
|
||||
requestWasSent.set(false);
|
||||
assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(order.getCertificate().getLocation(), is(url("https://example.com/acme/cert/1234")));
|
||||
assertThat(order.getStatus(), is(Status.PENDING));
|
||||
assertThat(order.getExpires(), is(parseTimestamp("2015-03-01T14:09:00Z")));
|
||||
assertThat(requestWasSent.get(), is(false));
|
||||
|
|
|
@ -15,7 +15,6 @@ package org.shredzone.acme4j;
|
|||
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.shredzone.acme4j.util.AcmeUtils.parseTimestamp;
|
||||
import static org.shredzone.acme4j.util.TestUtils.*;
|
||||
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
|
||||
|
||||
|
@ -25,14 +24,9 @@ import java.net.URI;
|
|||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.security.KeyPair;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.jose4j.jws.JsonWebSignature;
|
||||
|
@ -45,7 +39,6 @@ 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;
|
||||
|
@ -60,11 +53,9 @@ public class RegistrationTest {
|
|||
private URL resourceUrl = url("http://example.com/acme/resource");
|
||||
private URL locationUrl = url("http://example.com/acme/registration");
|
||||
private URI agreementUri = URI.create("http://example.com/agreement.pdf");
|
||||
private URL chainUrl = url("http://example.com/acme/chain");
|
||||
|
||||
/**
|
||||
* Test that a registration can be updated.
|
||||
* @throws URISyntaxException
|
||||
*/
|
||||
@Test
|
||||
public void testUpdateRegistration() throws AcmeException, IOException, URISyntaxException {
|
||||
|
@ -298,232 +289,6 @@ public class RegistrationTest {
|
|||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a order can be requested.
|
||||
*/
|
||||
@Test
|
||||
public void testOrder() throws AcmeException, IOException {
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public void sendSignedRequest(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("requestOrderRequest").toString()));
|
||||
assertThat(session, is(notNullValue()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int accept(int... httpStatus) throws AcmeException {
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_CREATED));
|
||||
return HttpURLConnection.HTTP_CREATED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSON readJsonResponse() {
|
||||
return getJSON("requestOrderResponse");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
|
||||
|
||||
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(), locationUrl);
|
||||
|
||||
Order order = registration.orderCertificate(csr, notBefore, notAfter);
|
||||
|
||||
assertThat(order.getLocation(), is(locationUrl));
|
||||
assertThat(order.getCsr(), is(csr));
|
||||
assertThat(order.getStatus(), is(Status.PENDING));
|
||||
assertThat(order.getExpires(), is(parseTimestamp("2016-01-01T00:00:00Z")));
|
||||
assertThat(order.getLocation(), is(locationUrl));
|
||||
assertThat(order.getNotBefore(), is(notBefore));
|
||||
assertThat(order.getNotAfter(), is(notAfter));
|
||||
assertThat(order.getCertificateLocation(), is(nullValue()));
|
||||
|
||||
List<Authorization> auths = order.getAuthorizations();
|
||||
assertThat(auths.size(), is(2));
|
||||
assertThat(auths.stream().map(Authorization::getLocation)::iterator,
|
||||
containsInAnyOrder(
|
||||
url("https://example.com/acme/authz/1234"),
|
||||
url("https://example.com/acme/authz/2345")));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a certificate can be requested and is delivered synchronously.
|
||||
*/
|
||||
@Test
|
||||
public void testRequestCertificateSync() throws AcmeException, IOException {
|
||||
final X509Certificate originalCert = TestUtils.createCertificate();
|
||||
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public void sendRequest(URL url, Session session) {
|
||||
fail("Attempted to download the certificate. Should be downloaded already!");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSignedRequest(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("requestCertificateRequestWithDate").toString()));
|
||||
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() {
|
||||
return originalCert;
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLink(String relation) {
|
||||
switch(relation) {
|
||||
case "up": return chainUrl;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.NEW_CERT, resourceUrl);
|
||||
|
||||
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(), locationUrl);
|
||||
Certificate cert = registration.requestCertificate(csr, notBefore, notAfter);
|
||||
|
||||
assertThat(cert.download(), is(originalCert));
|
||||
assertThat(cert.getLocation(), is(locationUrl));
|
||||
assertThat(cert.getChainLocation(), is(chainUrl));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a certificate can be requested and is delivered asynchronously.
|
||||
*/
|
||||
@Test
|
||||
public void testRequestCertificateAsync() throws AcmeException, IOException {
|
||||
TestableConnectionProvider provider = new TestableConnectionProvider() {
|
||||
@Override
|
||||
public void sendSignedRequest(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("requestCertificateRequest").toString()));
|
||||
assertThat(session, is(notNullValue()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int accept(int... httpStatus) throws AcmeException {
|
||||
assertThat(httpStatus, isIntArrayContainingInAnyOrder(
|
||||
HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_ACCEPTED));
|
||||
return HttpURLConnection.HTTP_ACCEPTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLink(String relation) {
|
||||
switch(relation) {
|
||||
case "up": return chainUrl;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.NEW_CERT, resourceUrl);
|
||||
|
||||
byte[] csr = TestUtils.getResourceAsByteArray("/csr.der");
|
||||
|
||||
Registration registration = new Registration(provider.createSession(), locationUrl);
|
||||
Certificate cert = registration.requestCertificate(csr);
|
||||
|
||||
assertThat(cert.getLocation(), is(locationUrl));
|
||||
assertThat(cert.getChainLocation(), is(chainUrl));
|
||||
|
||||
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(URL url, JSONBuilder claims, Session session) {
|
||||
assertThat(url, is(resourceUrl));
|
||||
assertThat(claims.toString(), sameJSONAs(getJSON("requestCertificateRequestWithDate").toString()));
|
||||
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 URL getLink(String relation) {
|
||||
switch(relation) {
|
||||
case "up": return chainUrl;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getLocation() {
|
||||
return locationUrl;
|
||||
}
|
||||
};
|
||||
|
||||
provider.putTestResource(Resource.NEW_CERT, resourceUrl);
|
||||
|
||||
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(), locationUrl);
|
||||
Certificate cert = registration.requestCertificate(csr, notBefore, notAfter);
|
||||
|
||||
assertThat(cert.getLocation(), is(locationUrl));
|
||||
assertThat(cert.getChainLocation(), is(chainUrl));
|
||||
|
||||
provider.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the account key can be changed.
|
||||
*/
|
||||
|
|
|
@ -21,6 +21,7 @@ import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
|
|||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
|
@ -47,6 +48,7 @@ import org.shredzone.acme4j.exception.AcmeProtocolException;
|
|||
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
|
||||
import org.shredzone.acme4j.exception.AcmeServerException;
|
||||
import org.shredzone.acme4j.provider.AcmeProvider;
|
||||
import org.shredzone.acme4j.util.AcmeUtils;
|
||||
import org.shredzone.acme4j.util.JSON;
|
||||
import org.shredzone.acme4j.util.JSONBuilder;
|
||||
import org.shredzone.acme4j.util.TestUtils;
|
||||
|
@ -759,20 +761,23 @@ public class DefaultConnectionTest {
|
|||
*/
|
||||
@Test
|
||||
public void testReadCertificate() throws Exception {
|
||||
X509Certificate original = TestUtils.createCertificate();
|
||||
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/pem-certificate-chain");
|
||||
when(mockUrlConnection.getInputStream()).thenReturn(getClass().getResourceAsStream("/cert.pem"));
|
||||
|
||||
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/pkix-cert");
|
||||
when(mockUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream(original.getEncoded()));
|
||||
|
||||
X509Certificate downloaded;
|
||||
List<X509Certificate> downloaded;
|
||||
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) {
|
||||
conn.conn = mockUrlConnection;
|
||||
downloaded = conn.readCertificate();
|
||||
downloaded = conn.readCertificates();
|
||||
}
|
||||
|
||||
assertThat(original, not(nullValue()));
|
||||
List<X509Certificate> original = TestUtils.createCertificate();
|
||||
assertThat(original.size(), is(2));
|
||||
|
||||
assertThat(downloaded, not(nullValue()));
|
||||
assertThat(original.getEncoded(), is(equalTo(downloaded.getEncoded())));
|
||||
assertThat(downloaded.size(), is(original.size()));
|
||||
for (int ix = 0; ix < downloaded.size(); ix++) {
|
||||
assertThat(downloaded.get(ix).getEncoded(), is(original.get(ix).getEncoded()));
|
||||
};
|
||||
|
||||
verify(mockUrlConnection).getHeaderField("Content-Type");
|
||||
verify(mockUrlConnection).getInputStream();
|
||||
|
@ -784,16 +789,25 @@ public class DefaultConnectionTest {
|
|||
*/
|
||||
@Test(expected = AcmeProtocolException.class)
|
||||
public void testReadBadCertificate() throws Exception {
|
||||
X509Certificate original = TestUtils.createCertificate();
|
||||
byte[] badCert = original.getEncoded();
|
||||
Arrays.sort(badCert); // break it
|
||||
// Build a broken certificate chain PEM file
|
||||
byte[] brokenPem;
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
OutputStreamWriter w = new OutputStreamWriter(baos)) {
|
||||
for (X509Certificate cert : TestUtils.createCertificate()) {
|
||||
byte[] badCert = cert.getEncoded();
|
||||
Arrays.sort(badCert); // break it
|
||||
AcmeUtils.writeToPem(badCert, "CERTIFICATE", w);
|
||||
}
|
||||
w.flush();
|
||||
brokenPem = baos.toByteArray();
|
||||
}
|
||||
|
||||
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/pkix-cert");
|
||||
when(mockUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream(badCert));
|
||||
when(mockUrlConnection.getHeaderField("Content-Type")).thenReturn("application/pem-certificate-chain");
|
||||
when(mockUrlConnection.getInputStream()).thenReturn(new ByteArrayInputStream(brokenPem));
|
||||
|
||||
try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) {
|
||||
conn.conn = mockUrlConnection;
|
||||
conn.readCertificate();
|
||||
conn.readCertificates();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import java.net.URI;
|
|||
import java.net.URL;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.shredzone.acme4j.Session;
|
||||
import org.shredzone.acme4j.exception.AcmeException;
|
||||
|
@ -60,7 +61,7 @@ public class DummyConnection implements Connection {
|
|||
}
|
||||
|
||||
@Override
|
||||
public X509Certificate readCertificate() {
|
||||
public List<X509Certificate> readCertificates() throws AcmeException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
|
|
|
@ -17,14 +17,22 @@ import static org.hamcrest.Matchers.*;
|
|||
import static org.junit.Assert.*;
|
||||
import static org.shredzone.acme4j.util.AcmeUtils.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.security.KeyPair;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
import javax.xml.bind.DatatypeConverter;
|
||||
|
||||
|
@ -251,6 +259,33 @@ public class AcmeUtilsTest {
|
|||
assertThat(stripErrorPrefix(null), is(nullValue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {@link AcmeUtils#writeToPem(byte[], String, Writer)} writes a correct PEM
|
||||
* file.
|
||||
*/
|
||||
@Test
|
||||
public void testWriteToPem() throws IOException, CertificateEncodingException {
|
||||
List<X509Certificate> certChain = TestUtils.createCertificate();
|
||||
|
||||
ByteArrayOutputStream pemFile = new ByteArrayOutputStream();
|
||||
try (Writer w = new OutputStreamWriter(pemFile)) {
|
||||
for (X509Certificate cert : certChain) {
|
||||
AcmeUtils.writeToPem(cert.getEncoded(), "CERTIFICATE", w);
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream originalFile = new ByteArrayOutputStream();
|
||||
try (InputStream in = getClass().getResourceAsStream("/cert.pem")) {
|
||||
byte[] buffer = new byte[2048];
|
||||
int len;
|
||||
while ((len = in.read(buffer)) >= 0) {
|
||||
originalFile.write(buffer, 0, len);
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(pemFile.toByteArray(), is(originalFile.toByteArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the given time.
|
||||
*/
|
||||
|
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
package org.shredzone.acme4j.util;
|
||||
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
@ -38,6 +41,7 @@ import java.security.spec.InvalidKeySpecException;
|
|||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
|
@ -218,15 +222,17 @@ public final class TestUtils {
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates a standard certificate for testing. This certificate is read from a test
|
||||
* resource and is guaranteed not to change between test runs.
|
||||
* Creates a standard certificate chain for testing. This certificate is read from a
|
||||
* test resource and is guaranteed not to change between test runs.
|
||||
*
|
||||
* @return {@link X509Certificate} for testing
|
||||
* @return List of {@link X509Certificate} for testing
|
||||
*/
|
||||
public static X509Certificate createCertificate() throws IOException {
|
||||
try (InputStream cert = TestUtils.class.getResourceAsStream("/cert.pem")) {
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate) certificateFactory.generateCertificate(cert);
|
||||
public static List<X509Certificate> createCertificate() throws IOException {
|
||||
try (InputStream in = TestUtils.class.getResourceAsStream("/cert.pem")) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
return unmodifiableList(cf.generateCertificates(in).stream()
|
||||
.map(c -> (X509Certificate) c)
|
||||
.collect(toList()));
|
||||
} catch (CertificateException ex) {
|
||||
throw new IOException(ex);
|
||||
}
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
aEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFDCCAfygAwIBAgIIZF/FmWNLATcwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UE
|
||||
AxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0MzE4
|
||||
WhcNNDcwNDI2MTE0MzE4WjAoMSYwJAYDVQQDEx1QZWJibGUgSW50ZXJtZWRpYXRl
|
||||
IENBIDY0NWZjNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMKU292V
|
||||
U2wr0+8ZyFGZEEziJrqpNPyWPOZtPhjKQt2H8JeAuAnxBDSUjy8h88NKy/wakzIv
|
||||
v+sYWdfpdezg1Ba331KyN31HWX7AMij35cTBQEx+1rzi+9v2S7woGe2UCuSv6cdz
|
||||
nJaS0/NOvdDoPSGPctFwOBsCsgx6gr9m5ItanLXMCb8ToKVcUj6GOus0vpB3NNRb
|
||||
m8sial/o7Sd4cw52riov1mIkR7Pbi6iACGd/KhFxpKAXQ1UMPTd4tZYGU8pCfyiB
|
||||
0rddSmwhh8eWU5ONShLzHi1aDjiu4NEpRxp8K4Tf0MealIJpyjQf5NV8Dz+QG7aX
|
||||
DSoH0n+1tGMdMI8CAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQG
|
||||
CCsGAQUFBwMBBggrBgEFBQcDAjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB
|
||||
CwUAA4IBAQBwOTlS9hOo1RhD2/DgBYZl+gYhqjuBedDJfPS3K34n4Z1FVVG+F6IL
|
||||
zP5CGTIABI3L3Ri1pfgUh2lU5nWfE95gUCnmJf8UA0dp0roJInQ25ux/nKFwcuA/
|
||||
JL58QZ43TZ/T3BNm8aF/lPvkEut0HnCct1B5IYOzFhqmYS6+BtsiJ2qWxhjiP/yc
|
||||
CXq3U289glMeSo7mz6FaUEinx6CZL6qHe5Ins/hMo57Jjay32RHjOeFmx+IlCA0o
|
||||
6kXvrZJy1QUpiUkkV7vbnt/PvQLvKo43YR/MsvuYEiOcPoyt7b7FmZ5VXtCnKBcf
|
||||
6BcViMAeJ6QzC1qJI6HlWIoqzsO6SKuu
|
||||
-----END CERTIFICATE-----
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"certificate": "MIIDVzCCAj-gAwIBAgIJAM4KDTzb0Y7NMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQwHhcNMTUxMjEwMDAxMTA4WhcNMjUxMjA3MDAxMTA4WjBCMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr0g3w4C8xbj_5lzJiDxk0HkEJeZeyruq-0AzOPMigJZ7zxZtX_KUxOIHrQ4qjcFhl0DmQImoM0wESU-kcsjAHCx8E1lgRVlVsMfLAQPHkg5UybqfadzKT3ALcSD-9F9mVIP6liC_6KzLTASmx6zM7j92KTl1ArObZr5mh0jvSNORrMhEC4Byn3-NTxjuHON1rWppCMwpeNNhFzaAig3O8PY8IyaLXNP2Ac5pXn0iW16S-Im9by7751UeW5a7DznmuMEM-WY640ffJDQ4-I64H403uAgvvSu-BGw8SEEZGuBCxoCnG1g6y6OvJyN5TgqFdGosAfm1u-_MP1seoPdpBQIDAQABo1AwTjAdBgNVHQ4EFgQUrie5ZLOrA_HuhW1b_CHjzEvj34swHwYDVR0jBBgwFoAUrie5ZLOrA_HuhW1b_CHjzEvj34swDAYDVR0TBAUwAwEB_zANBgkqhkiG9w0BAQsFAAOCAQEAkSOP0FUgIIUeJTObgXrenHzZpLAkqXi37dgdYuPhNveo3agueP51N7yIoh6YGShiJ73Rvr-lVYTwFXStrLih1Wh3tWvksMxnvocgd7l6USRb5_AgH7eHeFK4DoCAak2hUAcCLDRJN3XMhNLpyJhw7GJxowVIGUlxcW5Asrmh9qflfyMyjripTP3CdHobmNcNHyScjNncKj37m8vomel9acekTtDl2Ci7nLdE-3VqQCXMIfLiF3PO0gGpKei0RuVCSOG6W83zVInCPd_l3aluSR-f_VZlk8KGQ4As4uTQi89j-J1YepzG0ASMZpjVbXeIg5QBAywVxBh5XVTz37KN8A",
|
||||
"certificate": "MIIDFzCCAf-gAwIBAgIIYZRPVr9ji5UwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0NDEzWhcNMjIwNDI2MTE0NDEzWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANCRYYYLLZxJeoJKOcSwe-VpwUR_vehvx1dMy1fZoK3UX9sDcc5kRKxQJ7vog7q6XG4vA4fGcrGAfG6AeuwplWq3kb3UzYeqJeESeoRG0QhWVwCtIUPPVjHaPS19jP1xaE0vsfzCP3gD4l6W9ZhYlIqirFHEFgK8aKtMxFsmEVR2cDOyH9S5Eoe7QAY43mcflSV6-BzULRwvtT6ds-0Upf0UMbzp0z8Vdx017MoZdDMAumTaQt8MuIbwxcmRBrZp3pltF3mjGvtBMmuEUoqkiLWtCzhiH2pq4T9LDBbilZmjgCWB9pLcqe-KxsdgmBSwPVB_3yhvDaAX0ZuvafjEF68CAwEAAaNXMFUwDgYDVR0PAQH_BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAtZKzESSFF9wVUQdjSe-2P-0OFR7vvfnABs0p1fRv3n17OEgwqiZEui8aUVkY_mzH90rnL25iIUt-7v4PUUIa7NgZ5adxNvnMvTpuQyFYSwfJODFHZTZnJQJikvmxa0hIoH-zV0s3Pe3OctNeBEMAu2Tq4KsZZY4hF3c7G0Uwe7vmmffgHtixADkbOKwqZm1fBzRx6CUjz3u-rmGa4b30unRuF81YI4jqyeOJGNezSYsvLPdInp-ISa9mbQvI09bZY_zis0uMGVFcNwKLX3X95xxMONdX7VUsEBq1rFz4ec7priCoiaEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4",
|
||||
"resource": "revoke-cert"
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"certificate": "MIIDVzCCAj-gAwIBAgIJAM4KDTzb0Y7NMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQwHhcNMTUxMjEwMDAxMTA4WhcNMjUxMjA3MDAxMTA4WjBCMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MRwwGgYDVQQKDBNEZWZhdWx0IENvbXBhbnkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr0g3w4C8xbj_5lzJiDxk0HkEJeZeyruq-0AzOPMigJZ7zxZtX_KUxOIHrQ4qjcFhl0DmQImoM0wESU-kcsjAHCx8E1lgRVlVsMfLAQPHkg5UybqfadzKT3ALcSD-9F9mVIP6liC_6KzLTASmx6zM7j92KTl1ArObZr5mh0jvSNORrMhEC4Byn3-NTxjuHON1rWppCMwpeNNhFzaAig3O8PY8IyaLXNP2Ac5pXn0iW16S-Im9by7751UeW5a7DznmuMEM-WY640ffJDQ4-I64H403uAgvvSu-BGw8SEEZGuBCxoCnG1g6y6OvJyN5TgqFdGosAfm1u-_MP1seoPdpBQIDAQABo1AwTjAdBgNVHQ4EFgQUrie5ZLOrA_HuhW1b_CHjzEvj34swHwYDVR0jBBgwFoAUrie5ZLOrA_HuhW1b_CHjzEvj34swDAYDVR0TBAUwAwEB_zANBgkqhkiG9w0BAQsFAAOCAQEAkSOP0FUgIIUeJTObgXrenHzZpLAkqXi37dgdYuPhNveo3agueP51N7yIoh6YGShiJ73Rvr-lVYTwFXStrLih1Wh3tWvksMxnvocgd7l6USRb5_AgH7eHeFK4DoCAak2hUAcCLDRJN3XMhNLpyJhw7GJxowVIGUlxcW5Asrmh9qflfyMyjripTP3CdHobmNcNHyScjNncKj37m8vomel9acekTtDl2Ci7nLdE-3VqQCXMIfLiF3PO0gGpKei0RuVCSOG6W83zVInCPd_l3aluSR-f_VZlk8KGQ4As4uTQi89j-J1YepzG0ASMZpjVbXeIg5QBAywVxBh5XVTz37KN8A",
|
||||
"certificate": "MIIDFzCCAf-gAwIBAgIIYZRPVr9ji5UwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UEAxMdUGViYmxlIEludGVybWVkaWF0ZSBDQSA2NDVmYzUwHhcNMTcwNDI2MTE0NDEzWhcNMjIwNDI2MTE0NDEzWjAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANCRYYYLLZxJeoJKOcSwe-VpwUR_vehvx1dMy1fZoK3UX9sDcc5kRKxQJ7vog7q6XG4vA4fGcrGAfG6AeuwplWq3kb3UzYeqJeESeoRG0QhWVwCtIUPPVjHaPS19jP1xaE0vsfzCP3gD4l6W9ZhYlIqirFHEFgK8aKtMxFsmEVR2cDOyH9S5Eoe7QAY43mcflSV6-BzULRwvtT6ds-0Upf0UMbzp0z8Vdx017MoZdDMAumTaQt8MuIbwxcmRBrZp3pltF3mjGvtBMmuEUoqkiLWtCzhiH2pq4T9LDBbilZmjgCWB9pLcqe-KxsdgmBSwPVB_3yhvDaAX0ZuvafjEF68CAwEAAaNXMFUwDgYDVR0PAQH_BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAtZKzESSFF9wVUQdjSe-2P-0OFR7vvfnABs0p1fRv3n17OEgwqiZEui8aUVkY_mzH90rnL25iIUt-7v4PUUIa7NgZ5adxNvnMvTpuQyFYSwfJODFHZTZnJQJikvmxa0hIoH-zV0s3Pe3OctNeBEMAu2Tq4KsZZY4hF3c7G0Uwe7vmmffgHtixADkbOKwqZm1fBzRx6CUjz3u-rmGa4b30unRuF81YI4jqyeOJGNezSYsvLPdInp-ISa9mbQvI09bZY_zis0uMGVFcNwKLX3X95xxMONdX7VUsEBq1rFz4ec7priCoiaEPAD7lAq7FFB1HHwVkPovtYQq7IKXS5VXr4",
|
||||
"resource": "revoke-cert",
|
||||
"reason": 1
|
||||
}
|
||||
|
|
|
@ -106,18 +106,15 @@ public class ClientTest {
|
|||
}
|
||||
|
||||
// Now request a signed certificate.
|
||||
Certificate certificate = reg.requestCertificate(csrb.getEncoded());
|
||||
Order order = reg.orderCertificate(csrb.getEncoded(), null, null);
|
||||
Certificate certificate = order.getCertificate();
|
||||
|
||||
LOG.info("Success! The certificate for domains " + domains + " has been generated!");
|
||||
LOG.info("Certificate URI: " + certificate.getLocation());
|
||||
|
||||
// Download the leaf certificate and certificate chain.
|
||||
X509Certificate cert = certificate.download();
|
||||
X509Certificate[] chain = certificate.downloadChain();
|
||||
|
||||
// Write a combined file containing the certificate and chain.
|
||||
try (FileWriter fw = new FileWriter(DOMAIN_CHAIN_FILE)) {
|
||||
CertificateUtils.writeX509CertificateChain(fw, cert, chain);
|
||||
certificate.writeCertificate(fw);
|
||||
}
|
||||
|
||||
// That's all! Configure your web server to use the DOMAIN_KEY_FILE and
|
||||
|
|
Loading…
Reference in New Issue