1 /* 2 * Copyright 2021 Google LLC 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * 15 * * Neither the name of Google LLC nor the names of its 16 * contributors may be used to endorse or promote products derived from 17 * this software without specific prior written permission. 18 * 19 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 */ 31 32 package com.google.auth.oauth2; 33 34 import static com.google.common.base.Preconditions.checkNotNull; 35 import static java.nio.charset.StandardCharsets.UTF_8; 36 37 import com.google.auth.ServiceAccountSigner.SigningException; 38 import com.google.common.base.Joiner; 39 import com.google.common.base.Splitter; 40 import com.google.common.io.BaseEncoding; 41 import com.google.errorprone.annotations.CanIgnoreReturnValue; 42 import java.net.URI; 43 import java.security.InvalidKeyException; 44 import java.security.MessageDigest; 45 import java.security.NoSuchAlgorithmException; 46 import java.text.ParseException; 47 import java.util.ArrayList; 48 import java.util.Collections; 49 import java.util.HashMap; 50 import java.util.List; 51 import java.util.Locale; 52 import java.util.Map; 53 import javax.annotation.Nullable; 54 import javax.crypto.Mac; 55 import javax.crypto.spec.SecretKeySpec; 56 57 /** 58 * Internal utility that signs AWS API requests based on the AWS Signature Version 4 signing 59 * process. 60 * 61 * @see <a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">AWS 62 * Signature V4</a> 63 */ 64 class AwsRequestSigner { 65 66 // AWS Signature Version 4 signing algorithm identifier. 67 private static final String HASHING_ALGORITHM = "AWS4-HMAC-SHA256"; 68 69 // The termination string for the AWS credential scope value as defined in 70 // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html 71 private static final String AWS_REQUEST_TYPE = "aws4_request"; 72 73 private final AwsSecurityCredentials awsSecurityCredentials; 74 private final Map<String, String> additionalHeaders; 75 private final String httpMethod; 76 private final String region; 77 private final String requestPayload; 78 private final URI uri; 79 private final AwsDates dates; 80 81 /** 82 * Internal constructor. 83 * 84 * @param awsSecurityCredentials AWS security credentials 85 * @param httpMethod the HTTP request method 86 * @param url the request URL 87 * @param region the targeted region 88 * @param requestPayload the request payload 89 * @param additionalHeaders a map of additional HTTP headers to be included with the signed 90 * request 91 */ AwsRequestSigner( AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region, @Nullable String requestPayload, @Nullable Map<String, String> additionalHeaders, @Nullable AwsDates awsDates)92 private AwsRequestSigner( 93 AwsSecurityCredentials awsSecurityCredentials, 94 String httpMethod, 95 String url, 96 String region, 97 @Nullable String requestPayload, 98 @Nullable Map<String, String> additionalHeaders, 99 @Nullable AwsDates awsDates) { 100 this.awsSecurityCredentials = checkNotNull(awsSecurityCredentials); 101 this.httpMethod = checkNotNull(httpMethod); 102 this.uri = URI.create(url).normalize(); 103 this.region = checkNotNull(region); 104 this.requestPayload = requestPayload == null ? "" : requestPayload; 105 this.additionalHeaders = 106 (additionalHeaders != null) 107 ? new HashMap<>(additionalHeaders) 108 : new HashMap<String, String>(); 109 this.dates = awsDates == null ? AwsDates.generateXAmzDate() : awsDates; 110 } 111 112 /** 113 * Signs the specified AWS API request. 114 * 115 * @return the {@link AwsRequestSignature} 116 */ sign()117 AwsRequestSignature sign() { 118 // Retrieve the service name. For example: iam.amazonaws.com host => iam service. 119 String serviceName = Splitter.on(".").split(uri.getHost()).iterator().next(); 120 121 Map<String, String> canonicalHeaders = getCanonicalHeaders(dates.getOriginalDate()); 122 // Headers must be sorted. 123 List<String> sortedHeaderNames = new ArrayList<>(); 124 for (String headerName : canonicalHeaders.keySet()) { 125 sortedHeaderNames.add(headerName.toLowerCase(Locale.US)); 126 } 127 Collections.sort(sortedHeaderNames); 128 129 String canonicalRequestHash = createCanonicalRequestHash(canonicalHeaders, sortedHeaderNames); 130 String credentialScope = 131 dates.getFormattedDate() + "/" + region + "/" + serviceName + "/" + AWS_REQUEST_TYPE; 132 String stringToSign = 133 createStringToSign(canonicalRequestHash, dates.getXAmzDate(), credentialScope); 134 String signature = 135 calculateAwsV4Signature( 136 serviceName, 137 awsSecurityCredentials.getSecretAccessKey(), 138 dates.getFormattedDate(), 139 region, 140 stringToSign); 141 142 String authorizationHeader = 143 generateAuthorizationHeader( 144 sortedHeaderNames, awsSecurityCredentials.getAccessKeyId(), credentialScope, signature); 145 146 return new AwsRequestSignature.Builder() 147 .setSignature(signature) 148 .setCanonicalHeaders(canonicalHeaders) 149 .setHttpMethod(httpMethod) 150 .setSecurityCredentials(awsSecurityCredentials) 151 .setCredentialScope(credentialScope) 152 .setUrl(uri.toString()) 153 .setDate(dates.getOriginalDate()) 154 .setRegion(region) 155 .setAuthorizationHeader(authorizationHeader) 156 .build(); 157 } 158 159 /** Task 1: Create a canonical request for Signature Version 4. */ createCanonicalRequestHash( Map<String, String> headers, List<String> sortedHeaderNames)160 private String createCanonicalRequestHash( 161 Map<String, String> headers, List<String> sortedHeaderNames) { 162 // Append the HTTP request method. 163 StringBuilder canonicalRequest = new StringBuilder(httpMethod).append("\n"); 164 165 // Append the path. 166 String urlPath = uri.getRawPath().isEmpty() ? "/" : uri.getRawPath(); 167 canonicalRequest.append(urlPath).append("\n"); 168 169 // Append the canonical query string. 170 String actionQueryString = uri.getRawQuery() != null ? uri.getRawQuery() : ""; 171 canonicalRequest.append(actionQueryString).append("\n"); 172 173 // Append the canonical headers. 174 StringBuilder canonicalHeaders = new StringBuilder(); 175 for (String headerName : sortedHeaderNames) { 176 canonicalHeaders.append(headerName).append(":").append(headers.get(headerName)).append("\n"); 177 } 178 canonicalRequest.append(canonicalHeaders).append("\n"); 179 180 // Append the signed headers. 181 canonicalRequest.append(Joiner.on(';').join(sortedHeaderNames)).append("\n"); 182 183 // Append the hashed request payload. 184 canonicalRequest.append(getHexEncodedSha256Hash(requestPayload.getBytes(UTF_8))); 185 186 // Return the hashed canonical request. 187 return getHexEncodedSha256Hash(canonicalRequest.toString().getBytes(UTF_8)); 188 } 189 190 /** Task 2: Create a string to sign for Signature Version 4. */ createStringToSign( String canonicalRequestHash, String xAmzDate, String credentialScope)191 private String createStringToSign( 192 String canonicalRequestHash, String xAmzDate, String credentialScope) { 193 return HASHING_ALGORITHM 194 + "\n" 195 + xAmzDate 196 + "\n" 197 + credentialScope 198 + "\n" 199 + canonicalRequestHash; 200 } 201 202 /** 203 * Task 3: Calculate the signature for AWS Signature Version 4. 204 * 205 * @param date the date used in the hashing process in YYYYMMDD format 206 */ calculateAwsV4Signature( String serviceName, String secret, String date, String region, String stringToSign)207 private String calculateAwsV4Signature( 208 String serviceName, String secret, String date, String region, String stringToSign) { 209 byte[] kDate = sign(("AWS4" + secret).getBytes(UTF_8), date.getBytes(UTF_8)); 210 byte[] kRegion = sign(kDate, region.getBytes(UTF_8)); 211 byte[] kService = sign(kRegion, serviceName.getBytes(UTF_8)); 212 byte[] kSigning = sign(kService, AWS_REQUEST_TYPE.getBytes(UTF_8)); 213 return BaseEncoding.base16().lowerCase().encode(sign(kSigning, stringToSign.getBytes(UTF_8))); 214 } 215 216 /** Task 4: Format the signature to be added to the HTTP request. */ generateAuthorizationHeader( List<String> sortedHeaderNames, String accessKeyId, String credentialScope, String signature)217 private String generateAuthorizationHeader( 218 List<String> sortedHeaderNames, 219 String accessKeyId, 220 String credentialScope, 221 String signature) { 222 return String.format( 223 "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", 224 HASHING_ALGORITHM, 225 accessKeyId, 226 credentialScope, 227 Joiner.on(';').join(sortedHeaderNames), 228 signature); 229 } 230 getCanonicalHeaders(String defaultDate)231 private Map<String, String> getCanonicalHeaders(String defaultDate) { 232 Map<String, String> headers = new HashMap<>(); 233 headers.put("host", uri.getHost()); 234 235 // Only add the date if it hasn't been specified through the "date" header. 236 if (!additionalHeaders.containsKey("date")) { 237 headers.put("x-amz-date", defaultDate); 238 } 239 240 if (awsSecurityCredentials.getSessionToken() != null 241 && !awsSecurityCredentials.getSessionToken().isEmpty()) { 242 headers.put("x-amz-security-token", awsSecurityCredentials.getSessionToken()); 243 } 244 245 // Add all additional headers. 246 for (String key : additionalHeaders.keySet()) { 247 // Header keys need to be lowercase. 248 headers.put(key.toLowerCase(Locale.US), additionalHeaders.get(key)); 249 } 250 return headers; 251 } 252 sign(byte[] key, byte[] value)253 private static byte[] sign(byte[] key, byte[] value) { 254 try { 255 String algorithm = "HmacSHA256"; 256 Mac mac = Mac.getInstance(algorithm); 257 mac.init(new SecretKeySpec(key, algorithm)); 258 return mac.doFinal(value); 259 } catch (NoSuchAlgorithmException e) { 260 // Will not occur as HmacSHA256 is supported. We may allow other algorithms in the future. 261 throw new RuntimeException("HmacSHA256 must be supported by the JVM.", e); 262 } catch (InvalidKeyException e) { 263 throw new SigningException("Invalid key used when calculating the AWS V4 Signature", e); 264 } 265 } 266 getHexEncodedSha256Hash(byte[] bytes)267 private static String getHexEncodedSha256Hash(byte[] bytes) { 268 try { 269 MessageDigest digest = MessageDigest.getInstance("SHA-256"); 270 return BaseEncoding.base16().lowerCase().encode(digest.digest(bytes)); 271 } catch (NoSuchAlgorithmException e) { 272 throw new RuntimeException("Failed to compute SHA-256 hash.", e); 273 } 274 } 275 newBuilder( AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region)276 static Builder newBuilder( 277 AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region) { 278 return new Builder(awsSecurityCredentials, httpMethod, url, region); 279 } 280 281 static class Builder { 282 283 private final AwsSecurityCredentials awsSecurityCredentials; 284 private final String httpMethod; 285 private final String url; 286 private final String region; 287 288 @Nullable private String requestPayload; 289 @Nullable private Map<String, String> additionalHeaders; 290 @Nullable private AwsDates dates; 291 Builder( AwsSecurityCredentials awsSecurityCredentials, String httpMethod, String url, String region)292 private Builder( 293 AwsSecurityCredentials awsSecurityCredentials, 294 String httpMethod, 295 String url, 296 String region) { 297 this.awsSecurityCredentials = awsSecurityCredentials; 298 this.httpMethod = httpMethod; 299 this.url = url; 300 this.region = region; 301 } 302 303 @CanIgnoreReturnValue setRequestPayload(String requestPayload)304 Builder setRequestPayload(String requestPayload) { 305 this.requestPayload = requestPayload; 306 return this; 307 } 308 309 @CanIgnoreReturnValue setAdditionalHeaders(Map<String, String> additionalHeaders)310 Builder setAdditionalHeaders(Map<String, String> additionalHeaders) { 311 if (additionalHeaders.containsKey("date") && additionalHeaders.containsKey("x-amz-date")) { 312 throw new IllegalArgumentException("One of {date, x-amz-date} can be specified, not both."); 313 } 314 try { 315 if (additionalHeaders.containsKey("date")) { 316 this.dates = AwsDates.fromDateHeader(additionalHeaders.get("date")); 317 } 318 if (additionalHeaders.containsKey("x-amz-date")) { 319 this.dates = AwsDates.fromXAmzDate(additionalHeaders.get("x-amz-date")); 320 } 321 } catch (ParseException e) { 322 throw new IllegalArgumentException("The provided date header value is invalid.", e); 323 } 324 325 this.additionalHeaders = additionalHeaders; 326 return this; 327 } 328 build()329 AwsRequestSigner build() { 330 return new AwsRequestSigner( 331 awsSecurityCredentials, 332 httpMethod, 333 url, 334 region, 335 requestPayload, 336 additionalHeaders, 337 dates); 338 } 339 } 340 } 341