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}