001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2023 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.connector;
015
016import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
017import static java.util.function.Predicate.not;
018import static java.util.stream.Collectors.toUnmodifiableList;
019
020import java.io.IOException;
021import java.io.InputStream;
022import java.net.MalformedURLException;
023import java.net.URI;
024import java.net.URL;
025import java.net.http.HttpClient;
026import java.net.http.HttpRequest;
027import java.net.http.HttpResponse;
028import java.security.KeyPair;
029import java.security.cert.CertificateException;
030import java.security.cert.CertificateFactory;
031import java.security.cert.X509Certificate;
032import java.time.Instant;
033import java.time.ZoneId;
034import java.time.ZonedDateTime;
035import java.time.format.DateTimeParseException;
036import java.util.Collection;
037import java.util.List;
038import java.util.Objects;
039import java.util.Optional;
040import java.util.Set;
041import java.util.function.Consumer;
042import java.util.regex.Matcher;
043import java.util.regex.Pattern;
044import java.util.zip.GZIPInputStream;
045
046import edu.umd.cs.findbugs.annotations.Nullable;
047import org.shredzone.acme4j.Login;
048import org.shredzone.acme4j.Problem;
049import org.shredzone.acme4j.Session;
050import org.shredzone.acme4j.exception.AcmeException;
051import org.shredzone.acme4j.exception.AcmeNetworkException;
052import org.shredzone.acme4j.exception.AcmeProtocolException;
053import org.shredzone.acme4j.exception.AcmeRateLimitedException;
054import org.shredzone.acme4j.exception.AcmeServerException;
055import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
056import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
057import org.shredzone.acme4j.toolbox.AcmeUtils;
058import org.shredzone.acme4j.toolbox.JSON;
059import org.shredzone.acme4j.toolbox.JSONBuilder;
060import org.shredzone.acme4j.toolbox.JoseUtils;
061import org.slf4j.Logger;
062import org.slf4j.LoggerFactory;
063
064/**
065 * Default implementation of {@link Connection}. It communicates with the ACME server via
066 * HTTP, with a client that is provided by the given {@link HttpConnector}.
067 */
068public class DefaultConnection implements Connection {
069    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);
070
071    private static final int HTTP_OK = 200;
072    private static final int HTTP_CREATED = 201;
073    private static final int HTTP_NO_CONTENT = 204;
074    private static final int HTTP_NOT_MODIFIED = 304;
075
076    private static final String ACCEPT_HEADER = "Accept";
077    private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
078    private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
079    private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
080    private static final String CACHE_CONTROL_HEADER = "Cache-Control";
081    private static final String CONTENT_TYPE_HEADER = "Content-Type";
082    private static final String DATE_HEADER = "Date";
083    private static final String EXPIRES_HEADER = "Expires";
084    private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
085    private static final String LAST_MODIFIED_HEADER = "Last-Modified";
086    private static final String LINK_HEADER = "Link";
087    private static final String LOCATION_HEADER = "Location";
088    private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
089    private static final String RETRY_AFTER_HEADER = "Retry-After";
090    private static final String DEFAULT_CHARSET = "utf-8";
091    private static final String MIME_JSON = "application/json";
092    private static final String MIME_JSON_PROBLEM = "application/problem+json";
093    private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain";
094
095    private static final URI BAD_NONCE_ERROR = URI.create("urn:ietf:params:acme:error:badNonce");
096    private static final int MAX_ATTEMPTS = 10;
097
098    private static final Pattern NO_CACHE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*no-(?:cache|store)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
099    private static final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*max-age=(\\d+)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
100    private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("^\\d+$");
101
102    protected final HttpConnector httpConnector;
103    protected final HttpClient httpClient;
104    protected @Nullable HttpResponse<InputStream> lastResponse;
105
106    /**
107     * Creates a new {@link DefaultConnection}.
108     *
109     * @param httpConnector
110     *         {@link HttpConnector} to be used for HTTP connections
111     */
112    public DefaultConnection(HttpConnector httpConnector) {
113        this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
114        this.httpClient = httpConnector.createClientBuilder().build();
115    }
116
117    @Override
118    public void resetNonce(Session session) throws AcmeException {
119        assertConnectionIsClosed();
120
121        try {
122            session.setNonce(null);
123
124            var newNonceUrl = session.resourceUrl(Resource.NEW_NONCE);
125
126            LOG.debug("HEAD {}", newNonceUrl);
127
128            sendRequest(session, newNonceUrl, b ->
129                    b.method("HEAD", HttpRequest.BodyPublishers.noBody()));
130
131            logHeaders();
132
133            var rc = getResponse().statusCode();
134            if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
135                throwAcmeException();
136            }
137
138            session.setNonce(getNonce()
139                    .orElseThrow(() -> new AcmeProtocolException("Server did not provide a nonce"))
140            );
141        } catch (IOException ex) {
142            throw new AcmeNetworkException(ex);
143        } finally {
144            close();
145        }
146    }
147
148    @Override
149    public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
150            throws AcmeException {
151        Objects.requireNonNull(url, "url");
152        Objects.requireNonNull(session, "session");
153        assertConnectionIsClosed();
154
155        LOG.debug("GET {}", url);
156
157        try {
158            sendRequest(session, url, builder -> {
159                builder.GET();
160                builder.header(ACCEPT_HEADER, MIME_JSON);
161                if (ifModifiedSince != null) {
162                    builder.header(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
163                }
164            });
165
166            logHeaders();
167
168            getNonce().ifPresent(session::setNonce);
169
170            var rc = getResponse().statusCode();
171            if (rc != HTTP_OK && rc != HTTP_CREATED && (rc != HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
172                throwAcmeException();
173            }
174            return rc;
175        } catch (IOException ex) {
176            throw new AcmeNetworkException(ex);
177        }
178    }
179
180    @Override
181    public int sendCertificateRequest(URL url, Login login) throws AcmeException {
182        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
183                login.getAccountLocation(), MIME_CERTIFICATE_CHAIN);
184    }
185
186    @Override
187    public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException {
188        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
189                login.getAccountLocation(), MIME_JSON);
190    }
191
192    @Override
193    public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
194        return sendSignedRequest(url, claims, login.getSession(), login.getKeyPair(),
195                login.getAccountLocation(), MIME_JSON);
196    }
197
198    @Override
199    public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair)
200            throws AcmeException {
201        return sendSignedRequest(url, claims, session, keypair, null, MIME_JSON);
202    }
203
204    @Override
205    public JSON readJsonResponse() throws AcmeException {
206        expectContentType(Set.of(MIME_JSON, MIME_JSON_PROBLEM));
207
208        try (var in = getResponseBody()) {
209            var result = JSON.parse(in);
210            LOG.debug("Result JSON: {}", result);
211            return result;
212        } catch (IOException ex) {
213            throw new AcmeNetworkException(ex);
214        }
215    }
216
217    @Override
218    public List<X509Certificate> readCertificates() throws AcmeException {
219        expectContentType(Set.of(MIME_CERTIFICATE_CHAIN));
220
221        try (var in = new TrimmingInputStream(getResponseBody())) {
222            var cf = CertificateFactory.getInstance("X.509");
223            return cf.generateCertificates(in).stream()
224                    .map(X509Certificate.class::cast)
225                    .collect(toUnmodifiableList());
226        } catch (IOException ex) {
227            throw new AcmeNetworkException(ex);
228        } catch (CertificateException ex) {
229            throw new AcmeProtocolException("Failed to read certificate", ex);
230        }
231    }
232
233    @Override
234    public Optional<String> getNonce() {
235        var nonceHeaderOpt = getResponse().headers()
236                .firstValue(REPLAY_NONCE_HEADER)
237                .map(String::trim)
238                .filter(not(String::isEmpty));
239        if (nonceHeaderOpt.isPresent()) {
240            var nonceHeader = nonceHeaderOpt.get();
241
242            if (!AcmeUtils.isValidBase64Url(nonceHeader)) {
243                throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
244            }
245
246            LOG.debug("Replay Nonce: {}", nonceHeader);
247        }
248        return nonceHeaderOpt;
249    }
250
251    @Override
252    public URL getLocation() {
253        return getResponse().headers()
254                .firstValue(LOCATION_HEADER)
255                .map(l -> {
256                    LOG.debug("Location: {}", l);
257                    return l;
258                })
259                .map(this::resolveRelative)
260                .orElseThrow(() -> new AcmeProtocolException("location header is missing"));
261    }
262
263    @Override
264    public Optional<ZonedDateTime> getLastModified() {
265        return getResponse().headers()
266                .firstValue(LAST_MODIFIED_HEADER)
267                .map(lm -> {
268                    try {
269                        return ZonedDateTime.parse(lm, RFC_1123_DATE_TIME);
270                    } catch (DateTimeParseException ex) {
271                        LOG.debug("Ignored invalid Last-Modified date: {}", lm, ex);
272                        return null;
273                    }
274                });
275    }
276
277    @Override
278    public Optional<ZonedDateTime> getExpiration() {
279        var cacheControlHeader = getResponse().headers()
280                .firstValue(CACHE_CONTROL_HEADER)
281                .filter(not(h -> NO_CACHE_PATTERN.matcher(h).matches()))
282                .map(MAX_AGE_PATTERN::matcher)
283                .filter(Matcher::matches)
284                .map(m -> Integer.parseInt(m.group(1)))
285                .filter(maxAge -> maxAge != 0)
286                .map(maxAge -> ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));
287
288        if (cacheControlHeader.isPresent()) {
289            return cacheControlHeader;
290        }
291
292        return getResponse().headers()
293                .firstValue(EXPIRES_HEADER)
294                .flatMap(header -> {
295                    try {
296                        return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
297                    } catch (DateTimeParseException ex) {
298                        LOG.debug("Ignored invalid Expires date: {}", header, ex);
299                        return Optional.empty();
300                    }
301                });
302    }
303
304    @Override
305    public Collection<URL> getLinks(String relation) {
306        return collectLinks(relation).stream()
307                .map(this::resolveRelative)
308                .collect(toUnmodifiableList());
309    }
310
311    @Override
312    public void close() {
313        lastResponse = null;
314    }
315
316    /**
317     * Sends a HTTP request via http client. This is the central method to be used for
318     * sending. It will create a {@link HttpRequest} by using the request builder,
319     * configure commnon headers, and then send the request via {@link HttpClient}.
320     *
321     * @param session
322     *         {@link Session} to be used for sending
323     * @param url
324     *         Target {@link URL}
325     * @param body
326     *         Callback that completes the {@link HttpRequest.Builder} with the request
327     *         body (e.g. HTTP method, request body, more headers).
328     */
329    protected void sendRequest(Session session, URL url, Consumer<HttpRequest.Builder> body) throws IOException {
330        try {
331            var builder = httpConnector.createRequestBuilder(url)
332                    .header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET)
333                    .header(ACCEPT_LANGUAGE_HEADER, session.getLanguageHeader());
334
335            if (session.networkSettings().isCompressionEnabled()) {
336                builder.header(ACCEPT_ENCODING_HEADER, "gzip");
337            }
338
339            body.accept(builder);
340
341            lastResponse = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
342        } catch (InterruptedException ex) {
343            throw new IOException("Request was interrupted", ex);
344        }
345    }
346
347    /**
348     * Sends a signed POST request.
349     *
350     * @param url
351     *         {@link URL} to send the request to.
352     * @param claims
353     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
354     *         request.
355     * @param session
356     *         {@link Session} instance to be used for signing and tracking
357     * @param keypair
358     *         {@link KeyPair} to be used for signing
359     * @param accountLocation
360     *         If set, the account location is set as "kid" header. If {@code null}, the
361     *         public key is set as "jwk" header.
362     * @param accept
363     *         Accept header
364     * @return HTTP 200 class status that was returned
365     */
366    protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims, Session session,
367                                    KeyPair keypair, @Nullable URL accountLocation, String accept) throws AcmeException {
368        Objects.requireNonNull(url, "url");
369        Objects.requireNonNull(session, "session");
370        Objects.requireNonNull(keypair, "keypair");
371        Objects.requireNonNull(accept, "accept");
372        assertConnectionIsClosed();
373
374        var attempt = 1;
375        while (true) {
376            try {
377                return performRequest(url, claims, session, keypair, accountLocation, accept);
378            } catch (AcmeServerException ex) {
379                if (!BAD_NONCE_ERROR.equals(ex.getType())) {
380                    throw ex;
381                }
382                if (attempt == MAX_ATTEMPTS) {
383                    throw ex;
384                }
385                LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS);
386                attempt++;
387            }
388        }
389    }
390
391    /**
392     * Performs the POST request.
393     *
394     * @param url
395     *         {@link URL} to send the request to.
396     * @param claims
397     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
398     *         request.
399     * @param session
400     *         {@link Session} instance to be used for signing and tracking
401     * @param keypair
402     *         {@link KeyPair} to be used for signing
403     * @param accountLocation
404     *         If set, the account location is set as "kid" header. If {@code null}, the
405     *         public key is set as "jwk" header.
406     * @param accept
407     *         Accept header
408     * @return HTTP 200 class status that was returned
409     */
410    private int performRequest(URL url, @Nullable JSONBuilder claims, Session session,
411                               KeyPair keypair, @Nullable URL accountLocation, String accept)
412            throws AcmeException {
413        try {
414            if (session.getNonce() == null) {
415                resetNonce(session);
416            }
417
418            var jose = JoseUtils.createJoseRequest(
419                    url,
420                    keypair,
421                    claims,
422                    session.getNonce(),
423                    accountLocation != null ? accountLocation.toString() : null
424            );
425
426            var outputData = jose.toString();
427
428            sendRequest(session, url, builder -> {
429                builder.POST(HttpRequest.BodyPublishers.ofString(outputData));
430                builder.header(ACCEPT_HEADER, accept);
431                builder.header(CONTENT_TYPE_HEADER, "application/jose+json");
432            });
433
434            logHeaders();
435
436            session.setNonce(getNonce().orElse(null));
437
438            var rc = getResponse().statusCode();
439            if (rc != HTTP_OK && rc != HTTP_CREATED) {
440                throwAcmeException();
441            }
442            return rc;
443        } catch (IOException ex) {
444            throw new AcmeNetworkException(ex);
445        }
446    }
447
448    @Override
449    public Optional<Instant> getRetryAfter() {
450        return getResponse().headers()
451                .firstValue(RETRY_AFTER_HEADER)
452                .map(this::parseRetryAfterHeader);
453    }
454
455    /**
456     * Parses the content of a Retry-After header. The header can either contain a
457     * relative or an absolute time.
458     *
459     * @param header
460     *         Retry-After header
461     * @return Instant given in the header
462     * @throws AcmeProtocolException
463     *         if the header content is invalid
464     */
465    private Instant parseRetryAfterHeader(String header) {
466        // See RFC 2616 section 14.37
467        try {
468            // delta-seconds
469            if (DIGITS_ONLY_PATTERN.matcher(header).matches()) {
470                var delta = Integer.parseInt(header);
471                var date = getResponse().headers().firstValue(DATE_HEADER)
472                        .map(d -> ZonedDateTime.parse(d, RFC_1123_DATE_TIME).toInstant())
473                        .orElseGet(Instant::now);
474                return date.plusSeconds(delta);
475            }
476
477            // HTTP-date
478            return ZonedDateTime.parse(header, RFC_1123_DATE_TIME).toInstant();
479        } catch (RuntimeException ex) {
480            throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
481        }
482    }
483
484    /**
485     * Provides an {@link InputStream} of the response body. If the stream is compressed,
486     * it will also take care for decompression.
487     */
488    private InputStream getResponseBody() throws IOException {
489        var stream = getResponse().body();
490        if (stream == null) {
491            throw new AcmeProtocolException("Unexpected empty response");
492        }
493
494        if (getResponse().headers().firstValue("Content-Encoding")
495                .filter("gzip"::equalsIgnoreCase)
496                .isPresent()) {
497            stream = new GZIPInputStream(stream);
498        }
499
500        return stream;
501    }
502
503    /**
504     * Throws an {@link AcmeException}. This method throws an exception that tries to
505     * explain the error as precisely as possible.
506     */
507    private void throwAcmeException() throws AcmeException {
508        try {
509            if (getResponse().headers().firstValue(CONTENT_TYPE_HEADER)
510                    .map(AcmeUtils::getContentType)
511                    .filter(MIME_JSON_PROBLEM::equals)
512                    .isEmpty()) {
513                // Generic HTTP error
514                throw new AcmeException("HTTP " + getResponse().statusCode());
515            }
516
517            var problem = new Problem(readJsonResponse(), getResponse().request().uri().toURL());
518
519            var error = AcmeUtils.stripErrorPrefix(problem.getType().toString());
520
521            if ("unauthorized".equals(error)) {
522                throw new AcmeUnauthorizedException(problem);
523            }
524
525            if ("userActionRequired".equals(error)) {
526                var tos = collectLinks("terms-of-service").stream()
527                        .findFirst()
528                        .map(this::resolveUri)
529                        .orElse(null);
530                throw new AcmeUserActionRequiredException(problem, tos);
531            }
532
533            if ("rateLimited".equals(error)) {
534                var retryAfter = getRetryAfter();
535                var rateLimits = getLinks("help");
536                throw new AcmeRateLimitedException(problem, retryAfter.orElse(null), rateLimits);
537            }
538
539            throw new AcmeServerException(problem);
540        } catch (IOException ex) {
541            throw new AcmeNetworkException(ex);
542        }
543    }
544
545    /**
546     * Checks if the returned content type is in the list of expected types.
547     *
548     * @param expectedTypes
549     *         content types that are accepted
550     * @throws AcmeProtocolException
551     *         if the returned content type is different
552     */
553    private void expectContentType(Set<String> expectedTypes) {
554        var contentType = getResponse().headers()
555                .firstValue(CONTENT_TYPE_HEADER)
556                .map(AcmeUtils::getContentType)
557                .orElseThrow(() -> new AcmeProtocolException("No content type header found"));
558        if (!expectedTypes.contains(contentType)) {
559            throw new AcmeProtocolException("Unexpected content type: " + contentType);
560        }
561    }
562
563    /**
564     * Returns the response of the last request. If there is no connection currently
565     * open, an exception is thrown instead.
566     * <p>
567     * Note that the response provides an {@link InputStream} that can be read only
568     * once.
569     */
570    private HttpResponse<InputStream> getResponse() {
571        if (lastResponse == null) {
572            throw new IllegalStateException("Not connected.");
573        }
574        return lastResponse;
575    }
576
577    /**
578     * Asserts that the connection is currently closed. Throws an exception if not.
579     */
580    private void assertConnectionIsClosed() {
581        if (lastResponse != null) {
582            throw new IllegalStateException("Previous connection is not closed.");
583        }
584    }
585
586    /**
587     * Log all HTTP headers in debug mode.
588     */
589    private void logHeaders() {
590        if (!LOG.isDebugEnabled()) {
591            return;
592        }
593
594        getResponse().headers().map().forEach((key, headers) ->
595                headers.forEach(value ->
596                        LOG.debug("HEADER {}: {}", key, value)
597                )
598        );
599    }
600
601    /**
602     * Collects links of the given relation.
603     *
604     * @param relation
605     *         Link relation
606     * @return Collection of links, unconverted
607     */
608    private Collection<String> collectLinks(String relation) {
609        var p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?" + Pattern.quote(relation) + "\"?");
610
611        return getResponse().headers().allValues(LINK_HEADER)
612                .stream()
613                .map(p::matcher)
614                .filter(Matcher::matches)
615                .map(m -> m.group(1))
616                .peek(location -> LOG.debug("Link: {} -> {}", relation, location))
617                .collect(toUnmodifiableList());
618    }
619
620    /**
621     * Resolves a relative link against the connection's last URL.
622     *
623     * @param link
624     *         Link to resolve. Absolute links are just converted to an URL.
625     * @return Absolute URL of the given link
626     */
627    private URL resolveRelative(String link) {
628        try {
629            return resolveUri(link).toURL();
630        } catch (MalformedURLException ex) {
631            throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
632        }
633    }
634
635    /**
636     * Resolves a relative URI against the connection's last URL.
637     *
638     * @param uri
639     *         URI to resolve
640     * @return Absolute URI of the given link
641     */
642    private URI resolveUri(String uri) {
643        return getResponse().request().uri().resolve(uri);
644    }
645
646}