1 // Copyright 2017 Google Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 // 15 //////////////////////////////////////////////////////////////////////////////// 16 17 package com.google.crypto.tink.util; 18 19 import com.google.api.client.http.GenericUrl; 20 import com.google.api.client.http.HttpHeaders; 21 import com.google.api.client.http.HttpRequest; 22 import com.google.api.client.http.HttpResponse; 23 import com.google.api.client.http.HttpStatusCodes; 24 import com.google.api.client.http.HttpTransport; 25 import com.google.api.client.http.javanet.NetHttpTransport; 26 import com.google.errorprone.annotations.CanIgnoreReturnValue; 27 import java.io.BufferedReader; 28 import java.io.IOException; 29 import java.io.InputStream; 30 import java.io.InputStreamReader; 31 import java.io.Reader; 32 import java.net.MalformedURLException; 33 import java.net.URL; 34 import java.nio.charset.Charset; 35 import java.util.Locale; 36 import java.util.concurrent.Executor; 37 import java.util.concurrent.Executors; 38 import java.util.regex.Matcher; 39 import java.util.regex.Pattern; 40 import javax.annotation.concurrent.GuardedBy; 41 42 /** 43 * Thread-safe downloader. 44 * 45 * <p>This class can be used to download keys from a remote HTTPS server. 46 * 47 * <h3>Usage</h3> 48 * 49 * <p>Use {@link KeysDownloader.Builder} to construct an instance and keep it as a singleton in a 50 * static final variable across requests. 51 * 52 * <p>When initializing your server, we also recommend that you call {@link #refreshInBackground()} 53 * to proactively fetch the data. 54 * 55 * @since 1.1.0 56 * @deprecated This is not supported by Tink, as it incurs a dependency on <code> 57 * com.google.api.client.http</code>. If you need this, please copy it into your codebase. 58 */ 59 @Deprecated 60 public class KeysDownloader { 61 private static final Charset UTF_8 = Charset.forName("UTF-8"); 62 63 /** Default HTTP transport used by this class. */ 64 private static final NetHttpTransport DEFAULT_HTTP_TRANSPORT = 65 new NetHttpTransport.Builder().build(); 66 67 private static final Executor DEFAULT_BACKGROUND_EXECUTOR = Executors.newCachedThreadPool(); 68 69 /** Pattern for the max-age header element of Cache-Control. */ 70 private static final Pattern MAX_AGE_PATTERN = Pattern.compile("\\s*max-age\\s*=\\s*(\\d+)\\s*"); 71 72 private final Executor backgroundExecutor; 73 private final HttpTransport httpTransport; 74 private final Object fetchDataLock; 75 private final Object instanceStateLock; 76 private final String url; 77 78 @GuardedBy("instanceStateLock") 79 private Runnable pendingRefreshRunnable; 80 81 @GuardedBy("instanceStateLock") 82 private String cachedData; 83 84 @GuardedBy("instanceStateLock") 85 private long cachedTimeInMillis; 86 87 @GuardedBy("instanceStateLock") 88 private long cacheExpirationDurationInMillis; 89 KeysDownloader(Executor backgroundExecutor, HttpTransport httpTransport, String url)90 public KeysDownloader(Executor backgroundExecutor, HttpTransport httpTransport, String url) { 91 validate(url); 92 this.backgroundExecutor = backgroundExecutor; 93 this.httpTransport = httpTransport; 94 this.instanceStateLock = new Object(); 95 this.fetchDataLock = new Object(); 96 this.url = url; 97 this.cachedTimeInMillis = Long.MIN_VALUE; 98 this.cacheExpirationDurationInMillis = 0; 99 } 100 101 /** 102 * Returns a string containing a JSON with the Google public signing keys. 103 * 104 * <p>Meant to be called by {@link PaymentMethodTokenRecipient}. 105 */ download()106 public String download() throws IOException { 107 synchronized (instanceStateLock) { 108 // Checking and using the cache if required. 109 if (hasNonExpiredDataCached()) { 110 // Proactively triggering a refresh if we are close to the cache expiration. 111 if (shouldProactivelyRefreshDataInBackground()) { 112 refreshInBackground(); 113 } 114 return cachedData; 115 } 116 } 117 118 // Acquiring the fetch lock so we don't have multiple threads trying to fetch from the 119 // server at the same time. 120 synchronized (fetchDataLock) { 121 // It is possible that some other thread performed the fetch already and we don't need 122 // to fetch anymore, so double checking a fetch is still required. 123 synchronized (instanceStateLock) { 124 if (hasNonExpiredDataCached()) { 125 return cachedData; 126 } 127 } 128 // No other thread fetched, so it is up to this thread to fetch. 129 return fetchAndCacheData(); 130 } 131 } 132 getHttpTransport()133 public HttpTransport getHttpTransport() { 134 return httpTransport; 135 } 136 getUrl()137 public String getUrl() { 138 return url; 139 } 140 141 @GuardedBy("instanceStateLock") hasNonExpiredDataCached()142 private boolean hasNonExpiredDataCached() { 143 long currentTimeInMillis = getCurrentTimeInMillis(); 144 boolean cachedInFuture = cachedTimeInMillis > currentTimeInMillis; 145 boolean cacheExpired = 146 cachedTimeInMillis + cacheExpirationDurationInMillis <= currentTimeInMillis; 147 return !cacheExpired && !cachedInFuture; 148 } 149 150 @GuardedBy("instanceStateLock") shouldProactivelyRefreshDataInBackground()151 private boolean shouldProactivelyRefreshDataInBackground() { 152 // At half expiration duration, we should try to refresh. 153 return cachedTimeInMillis + (cacheExpirationDurationInMillis / 2) <= getCurrentTimeInMillis(); 154 } 155 156 /** 157 * Returns the current time in milliseconds since epoch. 158 * 159 * <p>Visible so tests can override it in subclasses. 160 */ getCurrentTimeInMillis()161 long getCurrentTimeInMillis() { 162 return System.currentTimeMillis(); 163 } 164 165 @GuardedBy("fetchDataLock") 166 @CanIgnoreReturnValue fetchAndCacheData()167 private String fetchAndCacheData() throws IOException { 168 long currentTimeInMillis = getCurrentTimeInMillis(); 169 HttpRequest httpRequest = 170 httpTransport.createRequestFactory().buildGetRequest(new GenericUrl(url)); 171 HttpResponse httpResponse = httpRequest.execute(); 172 if (httpResponse.getStatusCode() != HttpStatusCodes.STATUS_CODE_OK) { 173 throw new IOException("Unexpected status code = " + httpResponse.getStatusCode()); 174 } 175 String data; 176 InputStream contentStream = httpResponse.getContent(); 177 try { 178 InputStreamReader reader = new InputStreamReader(contentStream, UTF_8); 179 data = readerToString(reader); 180 } finally { 181 contentStream.close(); 182 } 183 synchronized (instanceStateLock) { 184 this.cachedTimeInMillis = currentTimeInMillis; 185 this.cacheExpirationDurationInMillis = 186 getExpirationDurationInSeconds(httpResponse.getHeaders()) * 1000; 187 this.cachedData = data; 188 } 189 return data; 190 } 191 192 /** Reads the contents of a {@link Reader} into a {@link String}. */ readerToString(Reader reader)193 private static String readerToString(Reader reader) throws IOException { 194 reader = new BufferedReader(reader); 195 StringBuilder stringBuilder = new StringBuilder(); 196 int c; 197 while ((c = reader.read()) != -1) { 198 stringBuilder.append((char) c); 199 } 200 return stringBuilder.toString(); 201 } 202 203 /** 204 * Gets the cache TimeInMillis in seconds. "max-age" in "Cache-Control" header and "Age" header 205 * are considered. 206 * 207 * @param httpHeaders the http header of the response 208 * @return the cache TimeInMillis in seconds or zero if the response should not be cached 209 */ getExpirationDurationInSeconds(HttpHeaders httpHeaders)210 long getExpirationDurationInSeconds(HttpHeaders httpHeaders) { 211 long expirationDurationInSeconds = 0; 212 if (httpHeaders.getCacheControl() != null) { 213 for (String arg : httpHeaders.getCacheControl().split(",")) { 214 Matcher m = MAX_AGE_PATTERN.matcher(arg); 215 if (m.matches()) { 216 expirationDurationInSeconds = Long.valueOf(m.group(1)); 217 break; 218 } 219 } 220 } 221 if (httpHeaders.getAge() != null) { 222 expirationDurationInSeconds -= httpHeaders.getAge(); 223 } 224 return Math.max(0, expirationDurationInSeconds); 225 } 226 227 /** Fetches keys in the background. */ refreshInBackground()228 public void refreshInBackground() { 229 Runnable refreshRunnable = newRefreshRunnable(); 230 synchronized (instanceStateLock) { 231 if (pendingRefreshRunnable != null) { 232 return; 233 } 234 pendingRefreshRunnable = refreshRunnable; 235 } 236 try { 237 backgroundExecutor.execute(refreshRunnable); 238 } catch (Throwable e) { 239 synchronized (instanceStateLock) { 240 // Clearing if we were still the pending runnable. 241 if (pendingRefreshRunnable == refreshRunnable) { 242 pendingRefreshRunnable = null; 243 } 244 } 245 throw e; 246 } 247 } 248 newRefreshRunnable()249 private Runnable newRefreshRunnable() { 250 return new Runnable() { 251 @Override 252 public void run() { 253 synchronized (fetchDataLock) { 254 try { 255 fetchAndCacheData(); 256 } catch (IOException e) { 257 // Failed to fetch the data. Ok as this was just from the background. 258 } finally { 259 synchronized (instanceStateLock) { 260 // Clearing if we were still the pending runnable. 261 if (pendingRefreshRunnable == this) { 262 pendingRefreshRunnable = null; 263 } 264 } 265 } 266 } 267 } 268 }; 269 } 270 271 private static void validate(String url) { 272 try { 273 URL tmp = new URL(url); 274 if (!tmp.getProtocol().toLowerCase(Locale.US).equals("https")) { 275 throw new IllegalArgumentException("url must point to a HTTPS server"); 276 } 277 } catch (MalformedURLException ex) { 278 throw new IllegalArgumentException(ex); 279 } 280 } 281 282 /** Builder for {@link KeysDownloader}. */ 283 public static class Builder { 284 private HttpTransport httpTransport = DEFAULT_HTTP_TRANSPORT; 285 private Executor executor = DEFAULT_BACKGROUND_EXECUTOR; 286 private String url; 287 288 /** Sets the url which must point to a HTTPS server. */ 289 @CanIgnoreReturnValue 290 public Builder setUrl(String val) { 291 this.url = val; 292 return this; 293 } 294 295 /** Sets the background executor. */ 296 @CanIgnoreReturnValue 297 public Builder setExecutor(Executor val) { 298 this.executor = val; 299 return this; 300 } 301 302 /** 303 * Sets the HTTP transport. 304 * 305 * <p>You generally should not need to set a custom transport as the default transport {@link 306 * KeysDownloader#DEFAULT_HTTP_TRANSPORT} should be suited for most use cases. 307 */ 308 @CanIgnoreReturnValue 309 public Builder setHttpTransport(HttpTransport httpTransport) { 310 this.httpTransport = httpTransport; 311 return this; 312 } 313 314 public KeysDownloader build() { 315 if (url == null) { 316 throw new IllegalArgumentException("must provide a url with {#setUrl}"); 317 } 318 return new KeysDownloader(executor, httpTransport, url); 319 } 320 } 321 } 322