001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2017 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.it.pebble;
015
016import static java.util.concurrent.TimeUnit.SECONDS;
017import static org.assertj.core.api.Assertions.assertThat;
018import static org.awaitility.Awaitility.await;
019import static org.junit.jupiter.api.Assertions.assertThrows;
020
021import java.net.URI;
022import java.security.KeyPair;
023import java.security.cert.X509Certificate;
024import java.time.Duration;
025import java.time.Instant;
026import java.time.temporal.ChronoUnit;
027
028import org.junit.jupiter.api.Test;
029import org.shredzone.acme4j.AccountBuilder;
030import org.shredzone.acme4j.Authorization;
031import org.shredzone.acme4j.Certificate;
032import org.shredzone.acme4j.Login;
033import org.shredzone.acme4j.RevocationReason;
034import org.shredzone.acme4j.Session;
035import org.shredzone.acme4j.Status;
036import org.shredzone.acme4j.challenge.Challenge;
037import org.shredzone.acme4j.challenge.Dns01Challenge;
038import org.shredzone.acme4j.challenge.Http01Challenge;
039import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
040import org.shredzone.acme4j.exception.AcmeException;
041import org.shredzone.acme4j.exception.AcmeServerException;
042
043/**
044 * Tests a complete certificate order with different challenges.
045 */
046public class OrderIT extends PebbleITBase {
047
048    private static final String TEST_DOMAIN = "example.com";
049
050    /**
051     * Test if a certificate can be ordered via http-01 challenge.
052     */
053    @Test
054    public void testHttpValidation() throws Exception {
055        orderCertificate(TEST_DOMAIN, auth -> {
056            var client = getBammBammClient();
057
058            var challenge = auth.findChallenge(Http01Challenge.class).orElseThrow();
059
060            client.httpAddToken(challenge.getToken(), challenge.getAuthorization());
061
062            cleanup(() -> client.httpRemoveToken(challenge.getToken()));
063
064            return challenge;
065        }, OrderIT::standardRevoker);
066    }
067
068    /**
069     * Test if a certificate can be ordered via dns-01 challenge.
070     */
071    @Test
072    public void testDnsValidation() throws Exception {
073        orderCertificate(TEST_DOMAIN, auth -> {
074            var client = getBammBammClient();
075
076            var challenge = auth.findChallenge(Dns01Challenge.class).orElseThrow();
077
078            var challengeDomainName = Dns01Challenge.toRRName(auth.getIdentifier());
079
080            client.dnsAddTxtRecord(challengeDomainName, challenge.getDigest());
081
082            cleanup(() -> client.dnsRemoveTxtRecord(challengeDomainName));
083
084            return challenge;
085        }, OrderIT::standardRevoker);
086    }
087
088    /**
089     * Test if a certificate can be ordered via tns-alpn-01 challenge.
090     */
091    @Test
092    public void testTlsAlpnValidation() throws Exception {
093        orderCertificate(TEST_DOMAIN, auth -> {
094            var client = getBammBammClient();
095
096            var challenge = auth.findChallenge(TlsAlpn01Challenge.class).orElseThrow();
097
098            client.tlsAlpnAddCertificate(
099                        auth.getIdentifier().getDomain(),
100                        challenge.getAuthorization());
101
102            cleanup(() -> client.tlsAlpnRemoveCertificate(auth.getIdentifier().getDomain()));
103
104            return challenge;
105        }, OrderIT::standardRevoker);
106    }
107
108    /**
109     * Test if a certificate can be revoked by its domain key.
110     */
111    @Test
112    public void testDomainKeyRevocation() throws Exception {
113        orderCertificate(TEST_DOMAIN, auth -> {
114            var client = getBammBammClient();
115
116            var challenge = auth.findChallenge(Http01Challenge.class).orElseThrow();
117
118            client.httpAddToken(challenge.getToken(), challenge.getAuthorization());
119
120            cleanup(() -> client.httpRemoveToken(challenge.getToken()));
121
122            return challenge;
123        }, OrderIT::domainKeyRevoker);
124    }
125
126    /**
127     * Runs the complete process of ordering a certificate.
128     *
129     * @param domain
130     *            Name of the domain to order a certificate for
131     * @param validator
132     *            {@link Validator} that finds and prepares a {@link Challenge} for domain
133     *            validation
134     * @param revoker
135     *            {@link Revoker} that finally revokes the certificate
136     */
137    private void orderCertificate(String domain, Validator validator, Revoker revoker)
138            throws Exception {
139        var keyPair = createKeyPair();
140        var session = new Session(pebbleURI());
141
142        var account = new AccountBuilder()
143                    .agreeToTermsOfService()
144                    .useKeyPair(keyPair)
145                    .create(session);
146
147        var domainKeyPair = createKeyPair();
148
149        var notBefore = Instant.now().truncatedTo(ChronoUnit.SECONDS);
150        var notAfter = notBefore.plus(Duration.ofDays(20L));
151
152        var order = account.newOrder()
153                    .domain(domain)
154                    .notBefore(notBefore)
155                    .notAfter(notAfter)
156                    .create();
157        assertThat(order.getNotBefore().orElseThrow()).isEqualTo(notBefore);
158        assertThat(order.getNotAfter().orElseThrow()).isEqualTo(notAfter);
159        assertThat(order.getStatus()).isEqualTo(Status.PENDING);
160
161        for (var auth : order.getAuthorizations()) {
162            assertThat(auth.getIdentifier().getDomain()).isEqualTo(domain);
163            assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
164
165            if (auth.getStatus() == Status.VALID) {
166                continue;
167            }
168
169            var challenge = validator.prepare(auth);
170            challenge.trigger();
171
172            await()
173                .pollInterval(1, SECONDS)
174                .timeout(30, SECONDS)
175                .conditionEvaluationListener(cond -> updateAuth(auth))
176                .untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
177
178            assertThat(auth.getStatus()).isEqualTo(Status.VALID);
179        }
180
181        order.execute(domainKeyPair);
182
183        await()
184            .pollInterval(1, SECONDS)
185            .timeout(30, SECONDS)
186            .conditionEvaluationListener(cond -> updateOrder(order))
187            .untilAsserted(() -> assertThat(order.getStatus())
188                    .isNotIn(Status.PENDING, Status.PROCESSING, Status.READY));
189
190        assertThat(order.getStatus()).isEqualTo(Status.VALID);
191
192        var certificate = order.getCertificate();
193        var cert = certificate.getCertificate();
194        assertThat(cert).isNotNull();
195        assertThat(cert.getNotBefore().toInstant()).isEqualTo(notBefore);
196        assertThat(cert.getNotAfter().toInstant()).isEqualTo(notAfter);
197        assertThat(cert.getSubjectX500Principal().getName()).contains("CN=" + domain);
198
199        for (var auth :  order.getAuthorizations()) {
200            assertThat(auth.getStatus()).isEqualTo(Status.VALID);
201            auth.deactivate();
202            assertThat(auth.getStatus()).isEqualTo(Status.DEACTIVATED);
203        }
204
205        revoker.revoke(session, certificate, keyPair, domainKeyPair);
206
207        // Make sure certificate is revoked
208        var ex = assertThrows(AcmeException.class, () -> {
209            Login login2 = session.login(account.getLocation(), keyPair);
210            Certificate cert2 = login2.bindCertificate(certificate.getLocation());
211            cert2.download();
212        }, "Could download revoked cert");
213        assertThat(ex.getMessage()).isEqualTo("HTTP 404");
214
215        // Try to revoke again
216        var ex2 = assertThrows(AcmeServerException.class,
217                certificate::revoke,
218                "Could revoke again");
219        assertThat(ex2.getProblem().getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:alreadyRevoked"));
220    }
221
222    /**
223     * Revokes a certificate by calling {@link Certificate#revoke(RevocationReason)}.
224     * This is the standard way to revoke a certificate.
225     */
226    private static void standardRevoker(Session session, Certificate certificate,
227            KeyPair keyPair, KeyPair domainKeyPair) throws Exception {
228        certificate.revoke(RevocationReason.KEY_COMPROMISE);
229    }
230
231    /**
232     * Revokes a certificate by calling
233     * {@link Certificate#revoke(Session, KeyPair, X509Certificate, RevocationReason)}.
234     * This way can be used when the account key was lost.
235     */
236    private static void domainKeyRevoker(Session session, Certificate certificate,
237            KeyPair keyPair, KeyPair domainKeyPair) throws Exception {
238        Certificate.revoke(session, domainKeyPair, certificate.getCertificate(),
239                RevocationReason.KEY_COMPROMISE);
240    }
241
242    @FunctionalInterface
243    private interface Validator {
244        Challenge prepare(Authorization auth) throws Exception;
245    }
246
247    @FunctionalInterface
248    private interface Revoker {
249        void revoke(Session session, Certificate certificate, KeyPair keyPair,
250            KeyPair domainKeyPair) throws Exception;
251    }
252
253}