001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2016 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j;
015
016import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
017import static org.assertj.core.api.Assertions.assertThat;
018import static org.shredzone.acme4j.toolbox.TestUtils.*;
019
020import java.io.ByteArrayOutputStream;
021import java.io.IOException;
022import java.io.OutputStreamWriter;
023import java.net.HttpURLConnection;
024import java.net.URL;
025import java.security.KeyPair;
026import java.security.cert.X509Certificate;
027import java.time.Instant;
028import java.time.ZonedDateTime;
029import java.time.temporal.ChronoUnit;
030import java.util.Arrays;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.List;
034import java.util.Optional;
035
036import org.junit.jupiter.api.Test;
037import org.shredzone.acme4j.connector.Resource;
038import org.shredzone.acme4j.exception.AcmeException;
039import org.shredzone.acme4j.provider.TestableConnectionProvider;
040import org.shredzone.acme4j.toolbox.JSON;
041import org.shredzone.acme4j.toolbox.JSONBuilder;
042import org.shredzone.acme4j.toolbox.TestUtils;
043
044/**
045 * Unit tests for {@link Certificate}.
046 */
047public class CertificateTest {
048
049    private final URL resourceUrl = url("http://example.com/acme/resource");
050    private final URL locationUrl = url("http://example.com/acme/certificate");
051    private final URL alternate1Url = url("https://example.com/acme/alt-cert/1");
052    private final URL alternate2Url = url("https://example.com/acme/alt-cert/2");
053
054    /**
055     * Test that a certificate can be downloaded.
056     */
057    @Test
058    public void testDownload() throws Exception {
059        var originalCert = TestUtils.createCertificate("/cert.pem");
060        var alternateCert = TestUtils.createCertificate("/certid-cert.pem");
061
062        var provider = new TestableConnectionProvider() {
063            List<X509Certificate> sendCert;
064
065            @Override
066            public int sendCertificateRequest(URL url, Login login) {
067                assertThat(url).isIn(locationUrl, alternate1Url, alternate2Url);
068                assertThat(login).isNotNull();
069                if (locationUrl.equals(url)) {
070                    sendCert = originalCert;
071                } else {
072                    sendCert = alternateCert;
073                }
074                return HttpURLConnection.HTTP_OK;
075            }
076
077            @Override
078            public List<X509Certificate> readCertificates() {
079                return sendCert;
080            }
081
082            @Override
083            public Collection<URL> getLinks(String relation) {
084                assertThat(relation).isEqualTo("alternate");
085                return Arrays.asList(alternate1Url, alternate2Url);
086            }
087        };
088
089        var cert = new Certificate(provider.createLogin(), locationUrl);
090        cert.download();
091
092        var downloadedCert = cert.getCertificate();
093        assertThat(downloadedCert.getEncoded()).isEqualTo(originalCert.get(0).getEncoded());
094
095        var downloadedChain = cert.getCertificateChain();
096        assertThat(downloadedChain).hasSize(originalCert.size());
097        for (var ix = 0; ix < downloadedChain.size(); ix++) {
098            assertThat(downloadedChain.get(ix).getEncoded()).isEqualTo(originalCert.get(ix).getEncoded());
099        }
100
101        byte[] writtenPem;
102        byte[] originalPem;
103        try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) {
104            cert.writeCertificate(w);
105            w.flush();
106            writtenPem = baos.toByteArray();
107        }
108        try (var baos = new ByteArrayOutputStream(); var in = getClass().getResourceAsStream("/cert.pem")) {
109            int len;
110            var buffer = new byte[2048];
111            while((len = in.read(buffer)) >= 0) {
112                baos.write(buffer, 0, len);
113            }
114            originalPem = baos.toByteArray();
115        }
116        assertThat(writtenPem).isEqualTo(originalPem);
117
118        assertThat(cert.isIssuedBy("The ACME CA X1")).isFalse();
119        assertThat(cert.isIssuedBy(CERT_ISSUER)).isTrue();
120
121        assertThat(cert.getAlternates()).isNotNull();
122        assertThat(cert.getAlternates()).hasSize(2);
123        assertThat(cert.getAlternates()).element(0).isEqualTo(alternate1Url);
124        assertThat(cert.getAlternates()).element(1).isEqualTo(alternate2Url);
125
126        assertThat(cert.getAlternateCertificates()).isNotNull();
127        assertThat(cert.getAlternateCertificates()).hasSize(2);
128        assertThat(cert.getAlternateCertificates())
129                .element(0)
130                .extracting(Certificate::getLocation)
131                .isEqualTo(alternate1Url);
132        assertThat(cert.getAlternateCertificates())
133                .element(1)
134                .extracting(Certificate::getLocation)
135                .isEqualTo(alternate2Url);
136
137        assertThat(cert.findCertificate("The ACME CA X1")).
138                isEmpty();
139        assertThat(cert.findCertificate(CERT_ISSUER).orElseThrow())
140                .isSameAs(cert);
141        assertThat(cert.findCertificate("minica root ca 3a1356").orElseThrow())
142                .isSameAs(cert.getAlternateCertificates().get(0));
143        assertThat(cert.getAlternateCertificates().get(0).isIssuedBy("minica root ca 3a1356"))
144                .isTrue();
145
146        provider.close();
147    }
148
149    /**
150     * Test that a certificate can be revoked.
151     */
152    @Test
153    public void testRevokeCertificate() throws AcmeException, IOException {
154        var originalCert = TestUtils.createCertificate("/cert.pem");
155
156        var provider = new TestableConnectionProvider() {
157            private boolean certRequested = false;
158
159            @Override
160            public int sendCertificateRequest(URL url, Login login) {
161                assertThat(url).isEqualTo(locationUrl);
162                assertThat(login).isNotNull();
163                certRequested = true;
164                return HttpURLConnection.HTTP_OK;
165            }
166
167            @Override
168            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
169                assertThat(url).isEqualTo(resourceUrl);
170                assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateRequest").toString());
171                assertThat(login).isNotNull();
172                certRequested = false;
173                return HttpURLConnection.HTTP_OK;
174            }
175
176            @Override
177            public List<X509Certificate> readCertificates() {
178                assertThat(certRequested).isTrue();
179                return originalCert;
180            }
181
182            @Override
183            public Collection<URL> getLinks(String relation) {
184                assertThat(relation).isEqualTo("alternate");
185                return Collections.emptyList();
186            }
187        };
188
189        provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
190
191        var cert = new Certificate(provider.createLogin(), locationUrl);
192        cert.revoke();
193
194        provider.close();
195    }
196
197    /**
198     * Test that a certificate can be revoked with reason.
199     */
200    @Test
201    public void testRevokeCertificateWithReason() throws AcmeException, IOException {
202        var originalCert = TestUtils.createCertificate("/cert.pem");
203
204        var provider = new TestableConnectionProvider() {
205            private boolean certRequested = false;
206
207            @Override
208            public int sendCertificateRequest(URL url, Login login) {
209                assertThat(url).isEqualTo(locationUrl);
210                assertThat(login).isNotNull();
211                certRequested = true;
212                return HttpURLConnection.HTTP_OK;
213            }
214
215            @Override
216            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
217                assertThat(url).isEqualTo(resourceUrl);
218                assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString());
219                assertThat(login).isNotNull();
220                certRequested = false;
221                return HttpURLConnection.HTTP_OK;
222            }
223
224            @Override
225            public List<X509Certificate> readCertificates() {
226                assertThat(certRequested).isTrue();
227                return originalCert;
228            }
229
230            @Override
231            public Collection<URL> getLinks(String relation) {
232                assertThat(relation).isEqualTo("alternate");
233                return Collections.emptyList();
234            }
235        };
236
237        provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
238
239        var cert = new Certificate(provider.createLogin(), locationUrl);
240        cert.revoke(RevocationReason.KEY_COMPROMISE);
241
242        provider.close();
243    }
244
245    /**
246     * Test that numeric revocation reasons are correctly translated.
247     */
248    @Test
249    public void testRevocationReason() {
250        assertThat(RevocationReason.code(1))
251                .isEqualTo(RevocationReason.KEY_COMPROMISE);
252    }
253
254    /**
255     * Test that a certificate can be revoked by its domain key pair.
256     */
257    @Test
258    public void testRevokeCertificateByKeyPair() throws AcmeException, IOException {
259        var originalCert = TestUtils.createCertificate("/cert.pem");
260        var certKeyPair = TestUtils.createDomainKeyPair();
261
262        var provider = new TestableConnectionProvider() {
263            @Override
264            public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair) {
265                assertThat(url).isEqualTo(resourceUrl);
266                assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString());
267                assertThat(session).isNotNull();
268                assertThat(keypair).isEqualTo(certKeyPair);
269                return HttpURLConnection.HTTP_OK;
270            }
271        };
272
273        provider.putTestResource(Resource.REVOKE_CERT, resourceUrl);
274
275        var session = provider.createSession();
276
277        Certificate.revoke(session, certKeyPair, originalCert.get(0), RevocationReason.KEY_COMPROMISE);
278
279        provider.close();
280    }
281
282    /**
283     * Test that RenewalInfo is returned.
284     */
285    @Test
286    public void testRenewalInfo() throws AcmeException, IOException {
287        // certid-cert.pem and certId provided by draft-ietf-acme-ari-03 and known good
288        var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
289        var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
290        var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId);
291        var retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS);
292
293        var provider = new TestableConnectionProvider() {
294            private boolean certRequested = false;
295            private boolean infoRequested = false;
296
297            @Override
298            public int sendCertificateRequest(URL url, Login login) {
299                assertThat(url).isEqualTo(locationUrl);
300                assertThat(login).isNotNull();
301                certRequested = true;
302                return HttpURLConnection.HTTP_OK;
303            }
304
305            @Override
306            public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) {
307                assertThat(url).isEqualTo(certResourceUrl);
308                assertThat(session).isNotNull();
309                assertThat(ifModifiedSince).isNull();
310                infoRequested = true;
311                return HttpURLConnection.HTTP_OK;
312            }
313
314            @Override
315            public JSON readJsonResponse() {
316                assertThat(infoRequested).isTrue();
317                return getJSON("renewalInfo");
318            }
319
320            @Override
321            public List<X509Certificate> readCertificates() {
322                assertThat(certRequested).isTrue();
323                return certIdCert;
324            }
325
326            @Override
327            public Collection<URL> getLinks(String relation) {
328                return Collections.emptyList();
329            }
330
331            @Override
332            public Optional<Instant> getRetryAfter() {
333                return Optional.of(retryAfterInstant);
334            }
335        };
336
337        provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
338
339        var cert = new Certificate(provider.createLogin(), locationUrl);
340        assertThat(cert.getCertID()).isEqualTo("MFgwCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCBQCHZUMh");
341        assertThat(cert.hasRenewalInfo()).isTrue();
342        assertThat(cert.getRenewalInfoLocation())
343                .hasValue(certResourceUrl);
344
345        var renewalInfo = cert.getRenewalInfo();
346        assertThat(renewalInfo.getRetryAfter())
347                .isEmpty();
348        assertThat(renewalInfo.getSuggestedWindowStart())
349                .isEqualTo("2021-01-03T00:00:00Z");
350        assertThat(renewalInfo.getSuggestedWindowEnd())
351                .isEqualTo("2021-01-07T00:00:00Z");
352        assertThat(renewalInfo.getExplanation())
353                .isNotEmpty()
354                .contains(url("https://example.com/docs/example-mass-reissuance-event"));
355
356        assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant);
357        assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant);
358
359        provider.close();
360    }
361
362    /**
363     * Test that a certificate is marked as replaced.
364     */
365    @Test
366    public void testMarkedAsReplaced() throws AcmeException, IOException {
367        // certid-cert.pem and certId provided by draft-ietf-acme-ari-03 and known good
368        var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
369        var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
370        var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId);
371
372        var provider = new TestableConnectionProvider() {
373            private boolean certRequested = false;
374
375            @Override
376            public int sendCertificateRequest(URL url, Login login) {
377                assertThat(url).isEqualTo(locationUrl);
378                assertThat(login).isNotNull();
379                certRequested = true;
380                return HttpURLConnection.HTTP_OK;
381            }
382
383            @Override
384            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
385                assertThat(certRequested).isTrue();
386                assertThat(url).isEqualTo(resourceUrl);
387                assertThatJson(claims.toString()).isEqualTo(getJSON("replacedCertificateRequest").toString());
388                assertThat(login).isNotNull();
389                return HttpURLConnection.HTTP_OK;
390            }
391
392            @Override
393            public List<X509Certificate> readCertificates() {
394                assertThat(certRequested).isTrue();
395                return certIdCert;
396            }
397
398            @Override
399            public Collection<URL> getLinks(String relation) {
400                return Collections.emptyList();
401            }
402        };
403
404        provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
405
406        var cert = new Certificate(provider.createLogin(), locationUrl);
407        assertThat(cert.hasRenewalInfo()).isTrue();
408        assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl);
409
410        provider.close();
411    }
412
413}