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}