View Javadoc

1   package com.atlassian.asap.core.keys.publickey;
2   
3   import com.atlassian.asap.api.exception.CannotRetrieveKeyException;
4   import com.atlassian.asap.core.exception.PublicKeyNotFoundException;
5   import com.atlassian.asap.core.exception.PublicKeyRetrievalException;
6   import com.atlassian.asap.core.keys.KeyProvider;
7   import com.atlassian.asap.core.keys.PemReader;
8   import com.atlassian.asap.core.validator.ValidatedKeyId;
9   import org.apache.commons.lang3.StringUtils;
10  import org.apache.http.HttpEntity;
11  import org.apache.http.HttpHeaders;
12  import org.apache.http.HttpResponse;
13  import org.apache.http.HttpStatus;
14  import org.apache.http.client.HttpClient;
15  import org.apache.http.client.config.RequestConfig;
16  import org.apache.http.client.methods.HttpGet;
17  import org.apache.http.client.utils.HttpClientUtils;
18  import org.apache.http.entity.ContentType;
19  import org.apache.http.impl.client.DefaultRedirectStrategy;
20  import org.apache.http.impl.client.HttpClientBuilder;
21  import org.apache.http.impl.client.cache.CacheConfig;
22  import org.apache.http.impl.client.cache.CachingHttpClients;
23  import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
24  import org.slf4j.Logger;
25  import org.slf4j.LoggerFactory;
26  
27  import java.io.IOException;
28  import java.io.InputStreamReader;
29  import java.net.URI;
30  import java.nio.charset.Charset;
31  import java.nio.charset.StandardCharsets;
32  import java.security.PublicKey;
33  import java.util.Objects;
34  import java.util.Optional;
35  import java.util.concurrent.TimeUnit;
36  
37  import static com.google.common.base.Preconditions.checkArgument;
38  import static java.lang.String.format;
39  
40  /**
41   * Reads public keys from web servers using the HTTPS protocol.
42   */
43  public class HttpPublicKeyProvider implements KeyProvider<PublicKey> {
44      /**
45       * The default max connections per route. Note this can still be overridden using http client system property.
46       */
47      static final int DEFAULT_MAX_CONNECTIONS = 20;
48      static final String PEM_MIME_TYPE = "application/x-pem-file";
49      static final String ACCEPT_HEADER_VALUE = PEM_MIME_TYPE;
50  
51      private static final Logger logger = LoggerFactory.getLogger(HttpPublicKeyProvider.class);
52  
53      private final HttpClient httpClient;
54      private final PemReader pemReader;
55      private final URI baseUrl;
56  
57      /**
58       * Create a new {@link HttpPublicKeyProvider} instance.
59       *
60       * @param baseUrl    the base url of the public key server
61       * @param httpClient the http client to use for communicating with the public key server
62       * @param pemReader  the pem key reader to use for reading public keys in pem format
63       */
64      public HttpPublicKeyProvider(URI baseUrl, HttpClient httpClient, PemReader pemReader) {
65          this(baseUrl, httpClient, pemReader, false);
66      }
67  
68      HttpPublicKeyProvider(URI baseUrl, HttpClient httpClient, PemReader pemReader, boolean allowInsecureConnections) {
69          Objects.requireNonNull(baseUrl, "Base URL cannot be null");
70          checkArgument(baseUrl.isAbsolute(), "Base URL must be absolute"); // implies that scheme != null
71          checkArgument((allowInsecureConnections && "http".equals(baseUrl.getScheme()))
72                  || "https".equals(baseUrl.getScheme()), "Invalid base URL scheme");
73          checkArgument(StringUtils.endsWith(baseUrl.toString(), "/"), "Base URL does not end with trailing slash: " + baseUrl);
74  
75          this.baseUrl = baseUrl;
76          this.httpClient = Objects.requireNonNull(httpClient);
77          this.pemReader = Objects.requireNonNull(pemReader);
78      }
79  
80      @Override
81      public PublicKey getKey(ValidatedKeyId validatedKeyId) throws CannotRetrieveKeyException {
82          URI keyUrl = baseUrl.resolve(validatedKeyId.getKeyId());
83          logger.debug("Fetching public key {}", keyUrl);
84  
85          HttpResponse response = null;
86          try {
87              response = httpGetKey(keyUrl);
88              int statusCode = response.getStatusLine().getStatusCode();
89              switch (statusCode) {
90                  case HttpStatus.SC_OK:
91                      HttpEntity entity = response.getEntity();
92                      if (entity != null) {
93                          return readEntityAsPublicKey(validatedKeyId, keyUrl, entity);
94                      } else {
95                          logger.info("Unexpected empty HTTP response when trying to retrieve public key URL {}", keyUrl);
96                          throw new PublicKeyRetrievalException("Unexpected empty response getting public key", validatedKeyId, keyUrl);
97                      }
98                  case HttpStatus.SC_FORBIDDEN:
99                  case HttpStatus.SC_NOT_FOUND:
100                     final String statusReason = response.getStatusLine().getReasonPhrase();
101                     // log at info level because this can be caused by invalid input
102                     logger.info("Public key URL {} returned {} {}", keyUrl, statusCode, statusReason);
103                     throw new PublicKeyNotFoundException(format("Encountered %s %s for public key", statusCode, statusReason), validatedKeyId, keyUrl);
104                 default:
105                     logger.error("Unexpected HTTP status code {} when trying to retrieve public key URL {}", statusCode, keyUrl);
106                     throw new PublicKeyRetrievalException("Unexpected status code " + statusCode + " getting public key", validatedKeyId, keyUrl);
107             }
108         } catch (IOException e) {
109             logger.warn("A problem occurred when trying to retrieve public key from URL {}", keyUrl, e);
110             throw new PublicKeyRetrievalException("Error getting HTTPS public key - " + e.getMessage(), e, validatedKeyId, keyUrl);
111         } finally {
112             HttpClientUtils.closeQuietly(response);
113         }
114     }
115 
116     private PublicKey readEntityAsPublicKey(ValidatedKeyId validatedKeyId, URI keyUrl, HttpEntity entity) throws IOException, CannotRetrieveKeyException {
117         try (InputStreamReader reader = new InputStreamReader(entity.getContent(), getCharset(entity))) {
118             final String mimeType = ContentType.get(entity).getMimeType();
119             if (Objects.equals(PEM_MIME_TYPE, mimeType)) {
120                 return pemReader.readPublicKey(reader);
121             } else {
122                 throw new PublicKeyRetrievalException(
123                         format("Unexpected public key MIME type %s, expected %s", mimeType, PEM_MIME_TYPE), validatedKeyId, keyUrl);
124             }
125         }
126     }
127 
128     private Charset getCharset(HttpEntity entity) {
129         return Optional.ofNullable(ContentType.getOrDefault(entity).getCharset()).orElse(StandardCharsets.US_ASCII);
130     }
131 
132     private HttpResponse httpGetKey(URI keyUrl) throws IOException {
133         HttpGet httpGet = new HttpGet(keyUrl);
134         httpGet.setHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER_VALUE);
135         return httpClient.execute(httpGet);
136     }
137 
138     /**
139      * Constructs the HTTP client used by the provider if no other {@link HttpClient} is provided. Uses the builder from
140      * {@link #defaultHttpClientBuilder()}.
141      *
142      * @return a new {@link HttpClient}
143      */
144     public static HttpClient defaultHttpClient() {
145         return defaultHttpClientBuilder().build();
146     }
147 
148     /**
149      * Configures the HTTP client used by the provider with the necessary connection manager and request/cache config.
150      * You can use this to extend the configuration, e.g. adding interceptors for Zipkin.
151      *
152      * @return a configured {@link HttpClientBuilder}
153      * @since 2.11
154      */
155     public static HttpClientBuilder defaultHttpClientBuilder() {
156         PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
157         connectionManager.setDefaultMaxPerRoute(DEFAULT_MAX_CONNECTIONS);
158         connectionManager.setMaxTotal(DEFAULT_MAX_CONNECTIONS);
159 
160         RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
161         requestConfigBuilder.setConnectionRequestTimeout(((int) TimeUnit.SECONDS.toMillis(5)));
162         requestConfigBuilder.setConnectTimeout(((int) TimeUnit.SECONDS.toMillis(5)));
163         requestConfigBuilder.setSocketTimeout((int) TimeUnit.SECONDS.toMillis(10));
164 
165         CacheConfig cacheConfig = CacheConfig.custom()
166                 .setMaxCacheEntries(128)
167                 .setMaxObjectSize(2048) // keys (.pem) are small
168                 .setHeuristicCachingEnabled(false)
169                 .setSharedCache(false)
170                 .setAsynchronousWorkersMax(2)
171                 .build();
172 
173         return CachingHttpClients.custom()
174                 .setCacheConfig(cacheConfig)
175                 .setDefaultRequestConfig(requestConfigBuilder.build())
176                 .setConnectionManager(connectionManager)
177                 .useSystemProperties()
178                 .setRedirectStrategy(DefaultRedirectStrategy.INSTANCE)
179                 // assume that the public key repo will be implemented using S3
180                 .setServiceUnavailableRetryStrategy(new S3ServiceUnavailableRetryStrategy(2, 100));
181     }
182 
183     @Override
184     public String toString() {
185         return this.getClass().getSimpleName() + "{baseUrl=" + baseUrl + '}';
186     }
187 }