AwsRequestSignerV4.java

  1. /*
  2.  * Copyright (C) 2022, Workday Inc.
  3.  *
  4.  * This program and the accompanying materials are made available under the
  5.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  6.  * https://www.eclipse.org/org/documents/edl-v10.php.
  7.  *
  8.  * SPDX-License-Identifier: BSD-3-Clause
  9.  */

  10. package org.eclipse.jgit.transport;

  11. import java.net.HttpURLConnection;
  12. import java.net.URL;
  13. import java.nio.charset.StandardCharsets;
  14. import java.security.MessageDigest;
  15. import java.time.Instant;
  16. import java.time.OffsetDateTime;
  17. import java.time.ZoneOffset;
  18. import java.time.format.DateTimeFormatter;
  19. import java.util.HashMap;
  20. import java.util.Map;
  21. import java.util.stream.Collectors;

  22. import javax.crypto.Mac;
  23. import javax.crypto.spec.SecretKeySpec;

  24. import org.eclipse.jgit.internal.JGitText;
  25. import org.eclipse.jgit.util.Hex;
  26. import org.eclipse.jgit.util.HttpSupport;

  27. /**
  28.  * Utility class for signing requests to AWS service endpoints using the V4
  29.  * signing protocol.
  30.  *
  31.  * Reference implementation: <a href=
  32.  * "https://docs.aws.amazon.com/AmazonS3/latest/API/samples/AWSS3SigV4JavaSamples.zip">AWSS3SigV4JavaSamples.zip</a>
  33.  *
  34.  * @see <a href=
  35.  *      "https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">AWS
  36.  *      Signature Version 4</a>
  37.  *
  38.  * @since 5.13.1
  39.  */
  40. public final class AwsRequestSignerV4 {

  41.     /** AWS version 4 signing algorithm (for authorization header). **/
  42.     private static final String ALGORITHM = "HMAC-SHA256"; //$NON-NLS-1$

  43.     /** Java Message Authentication Code (MAC) algorithm name. **/
  44.     private static final String MAC_ALGORITHM = "HmacSHA256"; //$NON-NLS-1$

  45.     /** AWS version 4 signing scheme. **/
  46.     private static final String SCHEME = "AWS4"; //$NON-NLS-1$

  47.     /** AWS version 4 terminator string. **/
  48.     private static final String TERMINATOR = "aws4_request"; //$NON-NLS-1$

  49.     /** SHA-256 hash of an empty request body. **/
  50.     private static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; //$NON-NLS-1$

  51.     /** Date format for the 'x-amz-date' header. **/
  52.     private static final DateTimeFormatter AMZ_DATE_FORMAT = DateTimeFormatter
  53.             .ofPattern("yyyyMMdd'T'HHmmss'Z'"); //$NON-NLS-1$

  54.     /** Date format for the string-to-sign's scope. **/
  55.     private static final DateTimeFormatter SCOPE_DATE_FORMAT = DateTimeFormatter
  56.             .ofPattern("yyyyMMdd"); //$NON-NLS-1$

  57.     private AwsRequestSignerV4() {
  58.         // Don't instantiate utility class
  59.     }

  60.     /**
  61.      * Sign the provided request with an AWS4 signature as the 'Authorization'
  62.      * header.
  63.      *
  64.      * @param httpURLConnection
  65.      *            The request to sign.
  66.      * @param queryParameters
  67.      *            The query parameters being sent in the request.
  68.      * @param contentLength
  69.      *            The content length of the data being sent in the request
  70.      * @param bodyHash
  71.      *            Hex-encoded SHA-256 hash of the data being sent in the request
  72.      * @param serviceName
  73.      *            The signing name of the AWS service (e.g. "s3").
  74.      * @param regionName
  75.      *            The name of the AWS region that will handle the request (e.g.
  76.      *            "us-east-1").
  77.      * @param awsAccessKey
  78.      *            The user's AWS Access Key.
  79.      * @param awsSecretKey
  80.      *            The user's AWS Secret Key.
  81.      */
  82.     public static void sign(HttpURLConnection httpURLConnection,
  83.             Map<String, String> queryParameters, long contentLength,
  84.             String bodyHash, String serviceName, String regionName,
  85.             String awsAccessKey, char[] awsSecretKey) {
  86.         // get request headers
  87.         Map<String, String> headers = new HashMap<>();
  88.         httpURLConnection.getRequestProperties()
  89.                 .forEach((headerName, headerValues) -> headers.put(headerName,
  90.                         String.join(",", headerValues))); //$NON-NLS-1$

  91.         // add required content headers
  92.         if (contentLength > 0) {
  93.             headers.put(HttpSupport.HDR_CONTENT_LENGTH,
  94.                     String.valueOf(contentLength));
  95.         } else {
  96.             bodyHash = EMPTY_BODY_SHA256;
  97.         }
  98.         headers.put("x-amz-content-sha256", bodyHash); //$NON-NLS-1$

  99.         // add the 'x-amz-date' header
  100.         OffsetDateTime now = Instant.now().atOffset(ZoneOffset.UTC);
  101.         String amzDate = now.format(AMZ_DATE_FORMAT);
  102.         headers.put("x-amz-date", amzDate); //$NON-NLS-1$

  103.         // add the 'host' header
  104.         URL endpointUrl = httpURLConnection.getURL();
  105.         int port = endpointUrl.getPort();
  106.         String hostHeader = (port > -1)
  107.                 ? endpointUrl.getHost().concat(":" + port) //$NON-NLS-1$
  108.                 : endpointUrl.getHost();
  109.         headers.put("Host", hostHeader); //$NON-NLS-1$

  110.         // construct the canonicalized request
  111.         String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers);
  112.         String canonicalizedHeaders = getCanonicalizedHeaderString(headers);
  113.         String canonicalizedQueryParameters = getCanonicalizedQueryString(
  114.                 queryParameters);
  115.         String httpMethod = httpURLConnection.getRequestMethod();
  116.         String canonicalRequest = httpMethod + '\n'
  117.                 + getCanonicalizedResourcePath(endpointUrl) + '\n'
  118.                 + canonicalizedQueryParameters + '\n' + canonicalizedHeaders
  119.                 + '\n' + canonicalizedHeaderNames + '\n' + bodyHash;

  120.         // construct the string-to-sign
  121.         String scopeDate = now.format(SCOPE_DATE_FORMAT);
  122.         String scope = scopeDate + '/' + regionName + '/' + serviceName + '/'
  123.                 + TERMINATOR;
  124.         String stringToSign = SCHEME + '-' + ALGORITHM + '\n' + amzDate + '\n'
  125.                 + scope + '\n' + Hex.toHexString(hash(
  126.                         canonicalRequest.getBytes(StandardCharsets.UTF_8)));

  127.         // compute the signing key
  128.         byte[] secretKey = (SCHEME + new String(awsSecretKey)).getBytes();
  129.         byte[] dateKey = signStringWithKey(scopeDate, secretKey);
  130.         byte[] regionKey = signStringWithKey(regionName, dateKey);
  131.         byte[] serviceKey = signStringWithKey(serviceName, regionKey);
  132.         byte[] signingKey = signStringWithKey(TERMINATOR, serviceKey);
  133.         byte[] signature = signStringWithKey(stringToSign, signingKey);

  134.         // construct the authorization header
  135.         String credentialsAuthorizationHeader = "Credential=" + awsAccessKey //$NON-NLS-1$
  136.                 + '/' + scope;
  137.         String signedHeadersAuthorizationHeader = "SignedHeaders=" //$NON-NLS-1$
  138.                 + canonicalizedHeaderNames;
  139.         String signatureAuthorizationHeader = "Signature=" //$NON-NLS-1$
  140.                 + Hex.toHexString(signature);
  141.         String authorizationHeader = SCHEME + '-' + ALGORITHM + ' '
  142.                 + credentialsAuthorizationHeader + ", " //$NON-NLS-1$
  143.                 + signedHeadersAuthorizationHeader + ", " //$NON-NLS-1$
  144.                 + signatureAuthorizationHeader;

  145.         // Copy back the updated request headers
  146.         headers.forEach(httpURLConnection::setRequestProperty);

  147.         // Add the 'authorization' header
  148.         httpURLConnection.setRequestProperty(HttpSupport.HDR_AUTHORIZATION,
  149.                 authorizationHeader);
  150.     }

  151.     /**
  152.      * Calculates the hex-encoded SHA-256 hash of the provided byte array.
  153.      *
  154.      * @param data
  155.      *            Byte array to hash
  156.      *
  157.      * @return Hex-encoded SHA-256 hash of the provided byte array.
  158.      */
  159.     public static String calculateBodyHash(final byte[] data) {
  160.         return (data == null || data.length < 1) ? EMPTY_BODY_SHA256
  161.                 : Hex.toHexString(hash(data));
  162.     }

  163.     /**
  164.      * Construct a string listing all request headers in sorted case-insensitive
  165.      * order, separated by a ';'.
  166.      *
  167.      * @param headers
  168.      *            Map containing all request headers.
  169.      *
  170.      * @return String that lists all request headers in sorted case-insensitive
  171.      *         order, separated by a ';'.
  172.      */
  173.     private static String getCanonicalizeHeaderNames(
  174.             Map<String, String> headers) {
  175.         return headers.keySet().stream().map(String::toLowerCase).sorted()
  176.                 .collect(Collectors.joining(";")); //$NON-NLS-1$
  177.     }

  178.     /**
  179.      * Constructs the canonical header string for a request.
  180.      *
  181.      * @param headers
  182.      *            Map containing all request headers.
  183.      *
  184.      * @return The canonical headers with values for the request.
  185.      */
  186.     private static String getCanonicalizedHeaderString(
  187.             Map<String, String> headers) {
  188.         if (headers == null || headers.isEmpty()) {
  189.             return ""; //$NON-NLS-1$
  190.         }
  191.         StringBuilder sb = new StringBuilder();
  192.         headers.keySet().stream().sorted(String.CASE_INSENSITIVE_ORDER)
  193.                 .forEach(key -> {
  194.                     String header = key.toLowerCase().replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$
  195.                     String value = headers.get(key).replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$
  196.                     sb.append(header).append(':').append(value).append('\n');
  197.                 });
  198.         return sb.toString();
  199.     }

  200.     /**
  201.      * Constructs the canonicalized resource path for an AWS service endpoint.
  202.      *
  203.      * @param url
  204.      *            The AWS service endpoint URL, including the path to any
  205.      *            resource.
  206.      *
  207.      * @return The canonicalized resource path for the AWS service endpoint.
  208.      */
  209.     private static String getCanonicalizedResourcePath(URL url) {
  210.         if (url == null) {
  211.             return "/"; //$NON-NLS-1$
  212.         }
  213.         String path = url.getPath();
  214.         if (path == null || path.isEmpty()) {
  215.             return "/"; //$NON-NLS-1$
  216.         }
  217.         String encodedPath = HttpSupport.urlEncode(path, true);
  218.         if (encodedPath.startsWith("/")) { //$NON-NLS-1$
  219.             return encodedPath;
  220.         }
  221.         return "/".concat(encodedPath); //$NON-NLS-1$
  222.     }

  223.     /**
  224.      * Constructs the canonicalized query string for a request.
  225.      *
  226.      * @param queryParameters
  227.      *            The query parameters in the request.
  228.      *
  229.      * @return The canonicalized query string for the request.
  230.      */
  231.     public static String getCanonicalizedQueryString(
  232.             Map<String, String> queryParameters) {
  233.         if (queryParameters == null || queryParameters.isEmpty()) {
  234.             return ""; //$NON-NLS-1$
  235.         }
  236.         return queryParameters
  237.                 .keySet().stream().sorted().map(
  238.                         key -> HttpSupport.urlEncode(key, false) + '='
  239.                                 + HttpSupport.urlEncode(
  240.                                         queryParameters.get(key), false))
  241.                 .collect(Collectors.joining("&")); //$NON-NLS-1$
  242.     }

  243.     /**
  244.      * Hashes the provided byte array using the SHA-256 algorithm.
  245.      *
  246.      * @param data
  247.      *            The byte array to hash.
  248.      *
  249.      * @return Hashed string contents of the provided byte array.
  250.      */
  251.     public static byte[] hash(byte[] data) {
  252.         try {
  253.             MessageDigest md = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$
  254.             md.update(data);
  255.             return md.digest();
  256.         } catch (Exception e) {
  257.             throw new RuntimeException(
  258.                     JGitText.get().couldNotHashByteArrayWithSha256, e);
  259.         }
  260.     }

  261.     /**
  262.      * Signs the provided string data using the specified key.
  263.      *
  264.      * @param stringToSign
  265.      *            The string data to sign.
  266.      * @param key
  267.      *            The key material of the secret key.
  268.      *
  269.      * @return Signed string data.
  270.      */
  271.     private static byte[] signStringWithKey(String stringToSign, byte[] key) {
  272.         try {
  273.             byte[] data = stringToSign.getBytes(StandardCharsets.UTF_8);
  274.             Mac mac = Mac.getInstance(MAC_ALGORITHM);
  275.             mac.init(new SecretKeySpec(key, MAC_ALGORITHM));
  276.             return mac.doFinal(data);
  277.         } catch (Exception e) {
  278.             throw new RuntimeException(JGitText.get().couldNotSignStringWithKey,
  279.                     e);
  280.         }
  281.     }

  282. }