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