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