View Javadoc
1   package com.github.davidmoten.aws.lw.client.internal.auth;
2   
3   import java.net.URL;
4   import java.nio.charset.StandardCharsets;
5   import java.security.InvalidKeyException;
6   import java.security.NoSuchAlgorithmException;
7   import java.text.SimpleDateFormat;
8   import java.util.ArrayList;
9   import java.util.Collections;
10  import java.util.Date;
11  import java.util.List;
12  import java.util.Locale;
13  import java.util.Map;
14  import java.util.Map.Entry;
15  import java.util.Optional;
16  import java.util.SimpleTimeZone;
17  import java.util.SortedMap;
18  import java.util.TreeMap;
19  import java.util.stream.Collectors;
20  
21  import javax.crypto.Mac;
22  import javax.crypto.spec.SecretKeySpec;
23  
24  import com.github.davidmoten.aws.lw.client.internal.Clock;
25  import com.github.davidmoten.aws.lw.client.internal.util.Preconditions;
26  import com.github.davidmoten.aws.lw.client.internal.util.Util;
27  
28  /**
29   * Common methods and properties for all AWS4 signer variants
30   */
31  public final class AwsSignatureVersion4 {
32  
33      static final String ALGORITHM_HMAC_SHA256 = "HmacSHA256";
34      /** SHA256 hash of an empty request body **/
35      public static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
36      public static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
37  
38      public static final String SCHEME = "AWS4";
39      public static final String ALGORITHM = "HMAC-SHA256";
40      public static final String TERMINATOR = "aws4_request";
41  
42      /** format strings for the date/time and date stamps required during signing **/
43      private static final String ISO8601BasicFormat = "yyyyMMdd'T'HHmmss'Z'";
44      private static final String DateStringFormat = "yyyyMMdd";
45  
46      private AwsSignatureVersion4() {
47          // prevent instantiation
48      }
49  
50      /**
51       * Computes an AWS4 authorization for a request, suitable for embedding in query
52       * parameters.
53       * 
54       * @param endpointUrl     the url to which the request is being made
55       * @param httpMethod      the HTTP method (GET, POST, PUT, etc.)
56       * @param serviceName     the AWS service code (e.g iam)
57       * @param regionName      the AWS region name
58       * @param clock           provides a timestamp
59       * @param headers         The request headers; 'Host' and 'X-Amz-Date' will be
60       *                        added to this set.
61       * @param queryParameters Any query parameters that will be added to the
62       *                        endpoint. The parameters should be specified in
63       *                        canonical format.
64       * @param bodyHash        Precomputed SHA256 hash of the request body content;
65       *                        this value should also be set as the header
66       *                        'X-Amz-Content-SHA256' for non-streaming uploads.
67       * @param awsAccessKey    The user's AWS Access Key.
68       * @param awsSecretKey    The user's AWS Secret Key.
69       * @param sessionToken
70       * @return The computed authorization string for the request. This value needs
71       *         to be set as the header 'Authorization' on the subsequent HTTP
72       *         request.
73       */
74      public static String computeSignatureForQueryAuth(URL endpointUrl, String httpMethod,
75              String serviceName, Optional<String> regionName, Clock clock, Map<String, String> headers,
76              Map<String, String> queryParameters, String bodyHash, String awsAccessKey,
77              String awsSecretKey, Optional<String> sessionToken) {
78          // first get the date and time for the subsequent request, and convert
79          // to ISO 8601 format for use in signature generation
80          Date now = new Date(clock.time());
81          String dateTimeStamp = dateTimeFormat().format(now);
82  
83          // make sure "Host" header is added
84          String hostHeader = endpointUrl.getHost();
85          int port = endpointUrl.getPort();
86          if (port > -1) {
87              hostHeader = hostHeader.concat(":" + port);
88          }
89          headers.put("Host", hostHeader);
90  
91          // canonicalized headers need to be expressed in the query
92          // parameters processed in the signature
93          String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers);
94          String canonicalizedHeaders = getCanonicalizedHeaderString(headers);
95  
96          // we need scope as part of the query parameters
97          String dateStamp = dateStampFormat().format(now);
98          String scope = dateStamp + "/" + regionName.orElse("us-east-1") + "/" + serviceName + "/" + TERMINATOR;
99  
100         // add the fixed authorization params required by Signature V4
101         queryParameters.put("X-Amz-Algorithm", SCHEME + "-" + ALGORITHM);
102         queryParameters.put("X-Amz-Credential", awsAccessKey + "/" + scope);
103 
104         // x-amz-date is now added as a query parameter, but still need to be in ISO8601
105         // basic form
106         queryParameters.put("X-Amz-Date", dateTimeStamp);
107 
108         queryParameters.put("X-Amz-SignedHeaders", canonicalizedHeaderNames);
109         
110         if (sessionToken.isPresent()) {
111             queryParameters.put("X-Amz-Security-Token", sessionToken.get());
112         }
113 
114         // build the expanded canonical query parameter string that will go into the
115         // signature computation
116         String canonicalizedQueryParameters = getCanonicalizedQueryString(queryParameters);
117 
118         // express all the header and query parameter data as a canonical request string
119         String canonicalRequest = getCanonicalRequest(endpointUrl, httpMethod,
120                 canonicalizedQueryParameters, canonicalizedHeaderNames, canonicalizedHeaders,
121                 bodyHash);
122 
123         // construct the string to be signed
124         String stringToSign = getStringToSign(SCHEME, ALGORITHM, dateTimeStamp, scope,
125                 canonicalRequest);
126 //        System.out.println("--------- String to sign -----------");
127 //        System.out.println(stringToSign);
128 //        System.out.println("------------------------------------");
129 
130         // compute the signing key
131         byte[] kSecret = (SCHEME + awsSecretKey).getBytes(StandardCharsets.UTF_8);
132         byte[] kDate = sign(dateStamp, kSecret);
133         byte[] kRegion = sign(regionName.orElse("us-east-1"), kDate);
134         byte[] kService = sign(serviceName, kRegion);
135         byte[] kSigning = sign(TERMINATOR, kService);
136         byte[] signature = sign(stringToSign, kSigning);
137 
138         // form up the authorization parameters for the caller to place in the query
139         // string
140         StringBuilder authString = new StringBuilder();
141 
142         authString.append("X-Amz-Algorithm=" + queryParameters.get("X-Amz-Algorithm"));
143         authString.append("&X-Amz-Credential=" + queryParameters.get("X-Amz-Credential"));
144         authString.append("&X-Amz-Date=" + queryParameters.get("X-Amz-Date"));
145         authString.append("&X-Amz-Expires=" + queryParameters.get("X-Amz-Expires"));
146         authString.append("&X-Amz-SignedHeaders=" + queryParameters.get("X-Amz-SignedHeaders"));
147         authString.append("&X-Amz-Signature=" + Util.toHex(signature));
148         if (sessionToken.isPresent()) {
149             authString.append("&X-Amz-Security-Token=" + Util.urlEncode(sessionToken.get(), false));
150         }
151         return authString.toString();
152     }
153 
154     /**
155      * Computes an AWS4 signature for a request, ready for inclusion as an
156      * 'Authorization' header.
157      * 
158      * @param endpointUrl     the url to which the request is being made
159      * @param httpMethod      the HTTP method (GET, POST, PUT, etc.)
160      * @param serviceName     the AWS service code (e.g iam)
161      * @param regionName      the AWS region name
162      * @param clock           provides a timestamp
163      * @param headers         The request headers; 'Host' and 'X-Amz-Date' will be
164      *                        added to this set.
165      * @param queryParameters Any query parameters that will be added to the
166      *                        endpoint. The parameters should be specified in
167      *                        canonical format.
168      * @param bodyHash        Precomputed SHA256 hash of the request body content;
169      *                        this value should also be set as the header
170      *                        'X-Amz-Content-SHA256' for non-streaming uploads.
171      * @param awsAccessKey    The user's AWS Access Key.
172      * @param awsSecretKey    The user's AWS Secret Key.
173      * @return The computed authorization string for the request. This value needs
174      *         to be set as the header 'Authorization' on the subsequent HTTP
175      *         request.
176      */
177     public static String computeSignatureForAuthorizationHeader(URL endpointUrl, String httpMethod,
178             String serviceName, String regionName, Clock clock, Map<String, String> headers,
179             Map<String, String> queryParameters, String bodyHash, String awsAccessKey,
180             String awsSecretKey) {
181         Preconditions.checkNotNull(headers);
182         Preconditions.checkNotNull(queryParameters);
183         SimpleDateFormat dateTimeFormat = dateTimeFormat();
184         SimpleDateFormat dateStampFormat = dateStampFormat();
185         // first get the date and time for the subsequent request, and convert
186         // to ISO 8601 format for use in signature generation
187         Date now = new Date(clock.time());
188         String dateTimeStamp = dateTimeFormat.format(now);
189 
190         // update the headers with required 'x-amz-date' and 'host' values
191         headers.put("x-amz-date", dateTimeStamp);
192 
193         String hostHeader = endpointUrl.getHost();
194         int port = endpointUrl.getPort();
195         if (port > -1) {
196             hostHeader = hostHeader.concat(":" + port);
197         }
198         headers.put("Host", hostHeader);
199 
200         // canonicalize the headers; we need the set of header names as well as the
201         // names and values to go into the signature process
202         String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers);
203         String canonicalizedHeaders = getCanonicalizedHeaderString(headers);
204 
205         // if any query string parameters have been supplied, canonicalize them
206         String canonicalizedQueryParameters = getCanonicalizedQueryString(queryParameters);
207 //        System.out.println("--------- Canonical query string --------");
208 //        System.out.println(canonicalizedQueryParameters);
209 
210         // canonicalize the various components of the request
211         String canonicalRequest = getCanonicalRequest(endpointUrl, httpMethod,
212                 canonicalizedQueryParameters, canonicalizedHeaderNames, canonicalizedHeaders,
213                 bodyHash);
214 //        System.out.println("--------- Canonical request --------");
215 //        System.out.println(canonicalRequest);
216 //        System.out.println("------------------------------------");
217 
218         // construct the string to be signed
219         String dateStamp = dateStampFormat.format(now);
220         String scope = dateStamp + "/" + regionName + "/" + serviceName + "/" + TERMINATOR;
221         String stringToSign = getStringToSign(SCHEME, ALGORITHM, dateTimeStamp, scope,
222                 canonicalRequest);
223 //        System.out.println("--------- String to sign -----------");
224 //        System.out.println(stringToSign);
225 //        System.out.println("------------------------------------");
226 
227         // compute the signing key
228         byte[] kSecret = (SCHEME + awsSecretKey).getBytes(StandardCharsets.UTF_8);
229         byte[] kDate = sign(dateStamp, kSecret);
230         byte[] kRegion = sign(regionName, kDate);
231         byte[] kService = sign(serviceName, kRegion);
232         byte[] kSigning = sign(TERMINATOR, kService);
233         byte[] signature = sign(stringToSign, kSigning);
234 
235         String credentialsAuthorizationHeader = "Credential=" + awsAccessKey + "/" + scope;
236         String signedHeadersAuthorizationHeader = "SignedHeaders=" + canonicalizedHeaderNames;
237         String signatureAuthorizationHeader = "Signature=" + Util.toHex(signature);
238 
239         String authorizationHeader = SCHEME + "-" + ALGORITHM + " " + credentialsAuthorizationHeader
240                 + ", " + signedHeadersAuthorizationHeader + ", " + signatureAuthorizationHeader;
241         return authorizationHeader;
242     }
243 
244     static SimpleDateFormat dateTimeFormat() {
245         SimpleDateFormat sdf = new SimpleDateFormat(ISO8601BasicFormat);
246         sdf.setTimeZone(new SimpleTimeZone(0, "UTC"));
247         return sdf;
248     }
249 
250     static SimpleDateFormat dateStampFormat() {
251         SimpleDateFormat sdf = new SimpleDateFormat(DateStringFormat);
252         sdf.setTimeZone(new SimpleTimeZone(0, "UTC"));
253         return sdf;
254     }
255 
256     /**
257      * Returns the canonical string of header names that will be included in the
258      * signature. For AWS4, all header names must be included in the process in
259      * sorted canonicalized order.
260      * 
261      * @param headers input to convert to canonical string
262      * @return canonical header names string
263      */
264     static String getCanonicalizeHeaderNames(Map<String, String> headers) {
265         List<String> sortedHeaders = new ArrayList<String>();
266         sortedHeaders.addAll(headers.keySet());
267         Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
268 
269         StringBuilder buffer = new StringBuilder();
270         for (String header : sortedHeaders) {
271             if (buffer.length() > 0)
272                 buffer.append(";");
273             buffer.append(header.toLowerCase(Locale.ENGLISH));
274         }
275 
276         return buffer.toString();
277     }
278 
279     /**
280      * Returns the canonical headers string. For AWS4, all headers must be included
281      * in the signing process.
282      * 
283      * @param headers input to convert to canonical string
284      * @return canonical headers string
285      */
286     static String getCanonicalizedHeaderString(Map<String, String> headers) {
287 
288         // step1: sort the headers by case-insensitive order
289         List<String> sortedHeaders = new ArrayList<String>();
290         sortedHeaders.addAll(headers.keySet());
291         Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
292 
293         // step2: form the canonical header:value entries in sorted order.
294         // Multiple white spaces in the values should be compressed to a single
295         // space.
296         StringBuilder buffer = new StringBuilder();
297         for (String key : sortedHeaders) {
298             buffer.append(key.toLowerCase(Locale.ENGLISH).replaceAll("\\s+", " ") + ":"
299                     + headers.get(key).replaceAll("\\s+", " "));
300             buffer.append("\n");
301         }
302 
303         return buffer.toString();
304     }
305 
306     /**
307      * Returns the canonical request string to go into the signer process; this
308      * consists of several canonical sub-parts.
309      * 
310      * @param endpoint                 url to which the request is being made
311      * @param httpMethod               http method (e.g GET, POST)
312      * @param canonicalQueryParameters canonical query parameters string
313      * @param canonicalizedHeaderNames canonical header names string
314      * @param canonicalizedHeaders     canonical headers string
315      * @param bodyHash                 SHA-256 hash of request body
316      * @return canonical request string
317      */
318     static String getCanonicalRequest(URL endpoint, String httpMethod,
319             String canonicalQueryParameters, String canonicalizedHeaderNames,
320             String canonicalizedHeaders, String bodyHash) {
321         return httpMethod + "\n" //
322                 + getCanonicalizedResourcePath(endpoint) + "\n" //
323                 + canonicalQueryParameters + "\n" //
324                 + canonicalizedHeaders + "\n" //
325                 + canonicalizedHeaderNames + "\n" //
326                 + bodyHash;
327     }
328 
329     /**
330      * Returns the canonicalized resource path for the service endpoint.
331      * 
332      * @param endpoint url to which the request is being made
333      * @return canonicalized resource path
334      */
335     static String getCanonicalizedResourcePath(URL endpoint) {
336         Preconditions.checkNotNull(endpoint);
337         String path = endpoint.getPath();
338         if (path.isEmpty()) {
339             return "/";
340         } else {
341             return Util.urlEncode(path, true);
342         }
343     }
344 
345     /**
346      * Examines the specified query string parameters and returns a canonicalized
347      * form.
348      * <p>
349      * The canonicalized query string is formed by first sorting all the query
350      * string parameters, then URI encoding both the key and value and then joining
351      * them, in order, separating key value pairs with an '&'.
352      *
353      * @param parameters The query string parameters to be canonicalized.
354      *
355      * @return A canonicalized form for the specified query string parameters.
356      */
357     static String getCanonicalizedQueryString(Map<String, String> parameters) {
358         SortedMap<String, String> sorted = new TreeMap<String, String>();
359 
360         for (Entry<String, String> pair : parameters.entrySet()) {
361             sorted.put(Util.urlEncode(pair.getKey(), false),
362                     pair.getValue() == null ? null : Util.urlEncode(pair.getValue(), false));
363         }
364 
365         return sorted //
366                 .entrySet() //
367                 .stream() //
368                 .map(pair -> pair.getKey() + "=" + blankIfNull(pair.getValue()))
369                 .collect(Collectors.joining("&"));
370     }
371 
372     private static String blankIfNull(String s) {
373         return s == null ? "" : s;
374     }
375 
376     static String getStringToSign(String scheme, String algorithm, String dateTime, String scope,
377             String canonicalRequest) {
378         return scheme + "-" + algorithm + "\n" + dateTime + "\n" + scope + "\n"
379                 + Util.toHex(Util.sha256(canonicalRequest));
380     }
381 
382     static byte[] sign(String stringData, byte[] key) {
383         return sign(stringData, key, ALGORITHM_HMAC_SHA256);
384     }
385 
386     // VisibleForTesting
387     static byte[] sign(String stringData, byte[] key, String algorithm) {
388         try {
389             byte[] data = stringData.getBytes(StandardCharsets.UTF_8);
390             Mac mac = Mac.getInstance(algorithm);
391             mac.init(new SecretKeySpec(key, algorithm));
392             return mac.doFinal(data);
393         } catch (NoSuchAlgorithmException | InvalidKeyException e) {
394             throw new RuntimeException(e);
395         }
396     }
397 
398 }