]> source.dussan.org Git - jgit.git/commitdiff
AmazonS3: Add support for AWS API signature version 4 12/193712/6
authoreric.steele <eric.steele@workday.com>
Wed, 1 Jun 2022 01:03:17 +0000 (18:03 -0700)
committerMatthias Sohn <matthias.sohn@sap.com>
Mon, 13 Jun 2022 07:44:23 +0000 (09:44 +0200)
Updating the AmazonS3 class to support AWS Signature version 4 because
version 2 is no longer supported in all AWS regions. The version can be
selected with the new 'aws.api.signature.version' property (defaults to
2 for backwards compatibility). When set to '4', the user must also
specify the AWS region via the 'region' property. The 'region' property
must match the region that the 'domain' property resolves to.

Bug: 579907
Change-Id: If289dbc6d0f57323cfeaac2624c4eb5028f78d13

org.eclipse.jgit.test/tst-rsrc/jgit-s3-config.disabled.properties
org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
org.eclipse.jgit/src/org/eclipse/jgit/transport/AmazonS3.java
org.eclipse.jgit/src/org/eclipse/jgit/transport/AwsRequestSignerV4.java [new file with mode: 0644]
org.eclipse.jgit/src/org/eclipse/jgit/util/HttpSupport.java

index d540977e945655a380c5c09ebc13df9e235064d4..3f36282b9e962952697a11dd6df7afaa0c0ae9f3 100644 (file)
 # * https://docs.aws.amazon.com/AmazonS3/latest/dev/manage-lifecycle-using-console.html
 #
 
+# AWS API signature version (defaults to 2)
+# aws.api.signature.version=4
+
+# AWS S3 Region Domain (defaults to s3.amazonaws.com)
+# domain: s3-us-east-2.amazonaws.com
+
+# AWS S3 Region (required if aws.api.signature.version=4, must match domain)
+# region: us-east-2
+
 # Test bucket name
 test.bucket=jgit.eclipse.org
 
index 3acceab098db87fd20e5787889021f6d41440de4..3c0f75710e81e6128d8c0aa39db356f50d668cec 100644 (file)
@@ -209,12 +209,14 @@ couldNotGetAdvertisedRef=Remote {0} did not advertise Ref for branch {1}. This R
 couldNotGetRepoStatistics=Could not get repository statistics
 couldNotFindTabInLine=Could not find tab in line {0}. Tab is the mandatory separator for the Netscape Cookie File Format.
 couldNotFindSixTabsInLine=Could not find 6 tabs but only {0} in line '{1}'. 7 tab separated columns per line are mandatory for the Netscape Cookie File Format.
+couldNotHashByteArrayWithSha256=Could not hash byte array with SHA-256 algorithm.
 couldNotLockHEAD=Could not lock HEAD
 couldNotPersistCookies=Could not persist received cookies in file ''{0}''
 couldNotReadCookieFile=Could not read cookie file ''{0}''
 couldNotReadIndexInOneGo=Could not read index in one go, only {0} out of {1} read
 couldNotReadObjectWhileParsingCommit=Could not read an object while parsing commit {0}
 couldNotRewindToUpstreamCommit=Could not rewind to upstream commit
+couldNotSignStringWithKey=Could not sign string with key.
 couldNotURLEncodeToUTF8=Could not URL encode to UTF-8
 countingObjects=Counting objects
 corruptPack=Pack file {0} is corrupt, removing it from pack list
@@ -361,6 +363,7 @@ interruptedWriting=Interrupted writing {0}
 inTheFuture=in the future
 invalidAdvertisementOf=invalid advertisement of {0}
 invalidAncestryLength=Invalid ancestry length
+invalidAwsApiSignatureVersion=Invalid aws.api.signature.version: {0}
 invalidBooleanValue=Invalid boolean value: {0}.{1}={2}
 invalidChannel=Invalid channel {0}
 invalidCommitParentNumber=Invalid commit parent number
@@ -457,6 +460,7 @@ minutesAgo={0} minutes ago
 mismatchOffset=mismatch offset for object {0}
 mismatchCRC=mismatch CRC for object {0}
 missingAccesskey=Missing accesskey.
+missingAwsRegion=Missing region (e.g. us-west-2).
 missingConfigurationForKey=No value for key {0} found in configuration
 missingCookieFile=Configured http.cookieFile ''{0}'' is missing
 missingCRC=missing CRC for object {0}
@@ -738,6 +742,7 @@ unableToWrite=Unable to write {0}
 unableToSignCommitNoSecretKey=Unable to sign commit. Signing key not available.
 unauthorized=Unauthorized
 unencodeableFile=Unencodable file: {0}
+unexpectedAwsApiSignatureVersion=Unexpected AWS API Signature Version: {0}
 unexpectedCompareResult=Unexpected metadata comparison result: {0}
 unexpectedEndOfConfigFile=Unexpected end of config file
 unexpectedEndOfInput=Unexpected end of input
index 76340dabb3bf3e7e7348df3b249339783d8e948e..2b48bf5a1bcca28d898444359ef8303e0ea276d7 100644 (file)
@@ -238,12 +238,14 @@ public class JGitText extends TranslationBundle {
        /***/ public String couldNotFindSixTabsInLine;
        /***/ public String couldNotGetAdvertisedRef;
        /***/ public String couldNotGetRepoStatistics;
+       /***/ public String couldNotHashByteArrayWithSha256;
        /***/ public String couldNotLockHEAD;
        /***/ public String couldNotPersistCookies;
        /***/ public String couldNotReadCookieFile;
        /***/ public String couldNotReadIndexInOneGo;
        /***/ public String couldNotReadObjectWhileParsingCommit;
        /***/ public String couldNotRewindToUpstreamCommit;
+       /***/ public String couldNotSignStringWithKey;
        /***/ public String couldNotURLEncodeToUTF8;
        /***/ public String countingObjects;
        /***/ public String createBranchFailedUnknownReason;
@@ -389,6 +391,7 @@ public class JGitText extends TranslationBundle {
        /***/ public String inTheFuture;
        /***/ public String invalidAdvertisementOf;
        /***/ public String invalidAncestryLength;
+       /***/ public String invalidAwsApiSignatureVersion;
        /***/ public String invalidBooleanValue;
        /***/ public String invalidChannel;
        /***/ public String invalidCommitParentNumber;
@@ -485,6 +488,7 @@ public class JGitText extends TranslationBundle {
        /***/ public String mismatchOffset;
        /***/ public String mismatchCRC;
        /***/ public String missingAccesskey;
+       /***/ public String missingAwsRegion;
        /***/ public String missingConfigurationForKey;
        /***/ public String missingCookieFile;
        /***/ public String missingCRC;
@@ -766,6 +770,7 @@ public class JGitText extends TranslationBundle {
        /***/ public String unableToSignCommitNoSecretKey;
        /***/ public String unauthorized;
        /***/ public String unencodeableFile;
+       /***/ public String unexpectedAwsApiSignatureVersion;
        /***/ public String unexpectedCompareResult;
        /***/ public String unexpectedEndOfConfigFile;
        /***/ public String unexpectedEndOfInput;
index 9210ec1721844802f45910f7ff9cc6d995c00bff..3e5af76f89f3f8f635232dcdda6ce0512580e3ce 100644 (file)
@@ -31,6 +31,7 @@ import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.text.MessageFormat;
 import java.text.SimpleDateFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -46,7 +47,6 @@ import java.util.SortedMap;
 import java.util.TimeZone;
 import java.util.TreeMap;
 import java.util.stream.Collectors;
-import java.time.Instant;
 
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
@@ -85,6 +85,12 @@ import org.xml.sax.helpers.XMLReaderFactory;
 public class AmazonS3 {
        private static final Set<String> SIGNED_HEADERS;
 
+       private static final String AWS_API_V2 = "2"; //$NON-NLS-1$
+
+       private static final String AWS_API_V4 = "4"; //$NON-NLS-1$
+
+       private static final String AWS_S3_SERVICE_NAME = "s3"; //$NON-NLS-1$
+
        private static final String HMAC = "HmacSHA1"; //$NON-NLS-1$
 
        private static final String X_AMZ_ACL = "x-amz-acl"; //$NON-NLS-1$
@@ -134,11 +140,17 @@ public class AmazonS3 {
                }
        }
 
+       /** AWS API Signature Version. */
+       private final String awsApiSignatureVersion;
+
        /** AWSAccessKeyId, public string that identifies the user's account. */
        private final String publicKey;
 
        /** Decoded form of the private AWSSecretAccessKey, to sign requests. */
-       private final SecretKeySpec privateKey;
+       private final SecretKeySpec secretKeySpec;
+
+       /** AWSSecretAccessKey, private string used to access a user's account. */
+       private final char[] secretKey; // store as char[] for security
 
        /** Our HTTP proxy support, in case we are behind a firewall. */
        private final ProxySelector proxySelector;
@@ -158,8 +170,12 @@ public class AmazonS3 {
        /** S3 Bucket Domain. */
        private final String domain;
 
+       /** S3 Region. */
+       private final String region;
+
        /** Property names used in amazon connection configuration file. */
        interface Keys {
+               String AWS_API_SIGNATURE_VERSION = "aws.api.signature.version"; //$NON-NLS-1$
                String ACCESS_KEY = "accesskey"; //$NON-NLS-1$
                String SECRET_KEY = "secretkey"; //$NON-NLS-1$
                String PASSWORD = "password"; //$NON-NLS-1$
@@ -167,6 +183,7 @@ public class AmazonS3 {
                String CRYPTO_VER = "crypto.version"; //$NON-NLS-1$
                String ACL = "acl"; //$NON-NLS-1$
                String DOMAIN = "domain"; //$NON-NLS-1$
+               String REGION = "region"; //$NON-NLS-1$
                String HTTP_RETRY = "httpclient.retry-max"; //$NON-NLS-1$
                String TMP_DIR = "tmpdir"; //$NON-NLS-1$
        }
@@ -179,6 +196,12 @@ public class AmazonS3 {
         * For example:
         *
         * <pre>
+        * # AWS API signature version, must be one of:
+        * #   2 - deprecated (not supported in all AWS regions)
+        * #   4 - latest (supported in all AWS regions)
+        * # Defaults to 2.
+        * aws.api.signature.version: 4
+        *
         * # AWS Access and Secret Keys (required)
         * accesskey: &lt;YourAWSAccessKey&gt;
         * secretkey: &lt;YourAWSSecretKey&gt;
@@ -191,6 +214,9 @@ public class AmazonS3 {
         * # AWS S3 Region Domain (defaults to s3.amazonaws.com)
         * domain: s3.amazonaws.com
         *
+        * # AWS S3 Region (required if aws.api.signature.version = 4)
+        * region: us-west-2
+        *
         * # Number of times to retry after internal error from S3.
         * httpclient.retry-max: 3
         *
@@ -203,16 +229,34 @@ public class AmazonS3 {
         *            connection properties.
         */
        public AmazonS3(final Properties props) {
+               awsApiSignatureVersion = props
+                               .getProperty(Keys.AWS_API_SIGNATURE_VERSION, AWS_API_V2);
+               if (awsApiSignatureVersion.equals(AWS_API_V4)) {
+                       region = props.getProperty(Keys.REGION);
+                       if (region == null) {
+                               throw new IllegalArgumentException(
+                                               JGitText.get().missingAwsRegion);
+                       }
+               } else if (awsApiSignatureVersion.equals(AWS_API_V2)) {
+                       region = null;
+               } else {
+                       throw new IllegalArgumentException(MessageFormat.format(
+                                       JGitText.get().invalidAwsApiSignatureVersion,
+                                       awsApiSignatureVersion));
+               }
+
                domain = props.getProperty(Keys.DOMAIN, "s3.amazonaws.com"); //$NON-NLS-1$
 
                publicKey = props.getProperty(Keys.ACCESS_KEY);
                if (publicKey == null)
                        throw new IllegalArgumentException(JGitText.get().missingAccesskey);
 
-               final String secret = props.getProperty(Keys.SECRET_KEY);
-               if (secret == null)
+               final String secretKeyStr = props.getProperty(Keys.SECRET_KEY);
+               if (secretKeyStr == null) {
                        throw new IllegalArgumentException(JGitText.get().missingSecretkey);
-               privateKey = new SecretKeySpec(Constants.encodeASCII(secret), HMAC);
+               }
+               secretKeySpec = new SecretKeySpec(Constants.encodeASCII(secretKeyStr), HMAC);
+               secretKey = secretKeyStr.toCharArray();
 
                final String pacl = props.getProperty(Keys.ACL, "PRIVATE"); //$NON-NLS-1$
                if (StringUtils.equalsIgnoreCase("PRIVATE", pacl)) //$NON-NLS-1$
@@ -257,7 +301,7 @@ public class AmazonS3 {
                        throws IOException {
                for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
                        final HttpURLConnection c = open("GET", bucket, key); //$NON-NLS-1$
-                       authorize(c);
+                       authorize(c, Collections.emptyMap(), 0, null);
                        switch (HttpSupport.response(c)) {
                        case HttpURLConnection.HTTP_OK:
                                encryption.validate(c, X_AMZ_META);
@@ -338,7 +382,7 @@ public class AmazonS3 {
                        throws IOException {
                for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
                        final HttpURLConnection c = open("DELETE", bucket, key); //$NON-NLS-1$
-                       authorize(c);
+                       authorize(c, Collections.emptyMap(), 0, null);
                        switch (HttpSupport.response(c)) {
                        case HttpURLConnection.HTTP_NO_CONTENT:
                                return;
@@ -384,13 +428,16 @@ public class AmazonS3 {
                }
 
                final String md5str = Base64.encodeBytes(newMD5().digest(data));
+               final String bodyHash = awsApiSignatureVersion.equals(AWS_API_V4)
+                               ? AwsRequestSignerV4.calculateBodyHash(data)
+                               : null;
                final String lenstr = String.valueOf(data.length);
                for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
                        final HttpURLConnection c = open("PUT", bucket, key); //$NON-NLS-1$
                        c.setRequestProperty("Content-Length", lenstr); //$NON-NLS-1$
                        c.setRequestProperty("Content-MD5", md5str); //$NON-NLS-1$
                        c.setRequestProperty(X_AMZ_ACL, acl);
-                       authorize(c);
+                       authorize(c, Collections.emptyMap(), data.length, bodyHash);
                        c.setDoOutput(true);
                        c.setFixedLengthStreamingMode(data.length);
                        try (OutputStream os = c.getOutputStream()) {
@@ -465,6 +512,9 @@ public class AmazonS3 {
                        monitorTask = MessageFormat.format(JGitText.get().progressMonUploading, key);
 
                final String md5str = Base64.encodeBytes(csum);
+               final String bodyHash = awsApiSignatureVersion.equals(AWS_API_V4)
+                               ? AwsRequestSignerV4.calculateBodyHash(buf.toByteArray())
+                               : null;
                final long len = buf.length();
                for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
                        final HttpURLConnection c = open("PUT", bucket, key); //$NON-NLS-1$
@@ -472,7 +522,7 @@ public class AmazonS3 {
                        c.setRequestProperty("Content-MD5", md5str); //$NON-NLS-1$
                        c.setRequestProperty(X_AMZ_ACL, acl);
                        encryption.request(c, X_AMZ_META);
-                       authorize(c);
+                       authorize(c, Collections.emptyMap(), len, bodyHash);
                        c.setDoOutput(true);
                        monitor.beginTask(monitorTask, (int) (len / 1024));
                        try (OutputStream os = c.getOutputStream()) {
@@ -544,8 +594,13 @@ public class AmazonS3 {
                urlstr.append('.');
                urlstr.append(domain);
                urlstr.append('/');
-               if (key.length() > 0)
-                       HttpSupport.encode(urlstr, key);
+               if (key.length() > 0) {
+                       if (awsApiSignatureVersion.equals(AWS_API_V2)) {
+                               HttpSupport.encode(urlstr, key);
+                       } else if (awsApiSignatureVersion.equals(AWS_API_V4)) {
+                               urlstr.append(key);
+                       }
+               }
                if (!args.isEmpty()) {
                        final Iterator<Map.Entry<String, String>> i;
 
@@ -572,7 +627,18 @@ public class AmazonS3 {
                return c;
        }
 
-       void authorize(HttpURLConnection c) throws IOException {
+       void authorize(HttpURLConnection httpURLConnection,
+                       Map<String, String> queryParams, long contentLength,
+                       final String bodyHash) throws IOException {
+               if (awsApiSignatureVersion.equals(AWS_API_V2)) {
+                       authorizeV2(httpURLConnection);
+               } else if (awsApiSignatureVersion.equals(AWS_API_V4)) {
+                       AwsRequestSignerV4.sign(httpURLConnection, queryParams, contentLength, bodyHash, AWS_S3_SERVICE_NAME,
+                                       region, publicKey, secretKey);
+               }
+       }
+
+       void authorizeV2(HttpURLConnection c) throws IOException {
                final Map<String, List<String>> reqHdr = c.getRequestProperties();
                final SortedMap<String, String> sigHdr = new TreeMap<>();
                for (Map.Entry<String, List<String>> entry : reqHdr.entrySet()) {
@@ -609,7 +675,7 @@ public class AmazonS3 {
                final String sec;
                try {
                        final Mac m = Mac.getInstance(HMAC);
-                       m.init(privateKey);
+                       m.init(secretKeySpec);
                        sec = Base64.encodeBytes(m.doFinal(s.toString().getBytes(UTF_8)));
                } catch (NoSuchAlgorithmException e) {
                        throw new IOException(MessageFormat.format(JGitText.get().noHMACsupport, HMAC, e.getMessage()));
@@ -673,7 +739,7 @@ public class AmazonS3 {
 
                        for (int curAttempt = 0; curAttempt < maxAttempts; curAttempt++) {
                                final HttpURLConnection c = open("GET", bucket, "", args); //$NON-NLS-1$ //$NON-NLS-2$
-                               authorize(c);
+                               authorize(c, args, 0, null);
                                switch (HttpSupport.response(c)) {
                                case HttpURLConnection.HTTP_OK:
                                        truncated = false;
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/AwsRequestSignerV4.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/AwsRequestSignerV4.java
new file mode 100644 (file)
index 0000000..6b3d397
--- /dev/null
@@ -0,0 +1,313 @@
+/*
+ * Copyright (C) 2022, Workday Inc.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+package org.eclipse.jgit.transport;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.util.Hex;
+import org.eclipse.jgit.util.HttpSupport;
+
+/**
+ * Utility class for signing requests to AWS service endpoints using the V4
+ * signing protocol.
+ *
+ * Reference implementation: <a href=
+ * "https://docs.aws.amazon.com/AmazonS3/latest/API/samples/AWSS3SigV4JavaSamples.zip">AWSS3SigV4JavaSamples.zip</a>
+ *
+ * @see <a href=
+ *      "https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">AWS
+ *      Signature Version 4</a>
+ *
+ * @since 5.13
+ */
+public final class AwsRequestSignerV4 {
+
+       /** AWS version 4 signing algorithm (for authorization header). **/
+       private static final String ALGORITHM = "HMAC-SHA256"; //$NON-NLS-1$
+
+       /** Java Message Authentication Code (MAC) algorithm name. **/
+       private static final String MAC_ALGORITHM = "HmacSHA256"; //$NON-NLS-1$
+
+       /** AWS version 4 signing scheme. **/
+       private static final String SCHEME = "AWS4"; //$NON-NLS-1$
+
+       /** AWS version 4 terminator string. **/
+       private static final String TERMINATOR = "aws4_request"; //$NON-NLS-1$
+
+       /** SHA-256 hash of an empty request body. **/
+       private static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; //$NON-NLS-1$
+
+       /** Date format for the 'x-amz-date' header. **/
+       private static final DateTimeFormatter AMZ_DATE_FORMAT = DateTimeFormatter
+                       .ofPattern("yyyyMMdd'T'HHmmss'Z'"); //$NON-NLS-1$
+
+       /** Date format for the string-to-sign's scope. **/
+       private static final DateTimeFormatter SCOPE_DATE_FORMAT = DateTimeFormatter
+                       .ofPattern("yyyyMMdd"); //$NON-NLS-1$
+
+       private AwsRequestSignerV4() {
+               // Don't instantiate utility class
+       }
+
+       /**
+        * Sign the provided request with an AWS4 signature as the 'Authorization'
+        * header.
+        *
+        * @param httpURLConnection
+        *            The request to sign.
+        * @param queryParameters
+        *            The query parameters being sent in the request.
+        * @param contentLength
+        *            The content length of the data being sent in the request
+        * @param bodyHash
+        *            Hex-encoded SHA-256 hash of the data being sent in the request
+        * @param serviceName
+        *            The signing name of the AWS service (e.g. "s3").
+        * @param regionName
+        *            The name of the AWS region that will handle the request (e.g.
+        *            "us-east-1").
+        * @param awsAccessKey
+        *            The user's AWS Access Key.
+        * @param awsSecretKey
+        *            The user's AWS Secret Key.
+        */
+       public static void sign(HttpURLConnection httpURLConnection,
+                       Map<String, String> queryParameters, long contentLength,
+                       String bodyHash, String serviceName, String regionName,
+                       String awsAccessKey, char[] awsSecretKey) {
+               // get request headers
+               Map<String, String> headers = new HashMap<>();
+               httpURLConnection.getRequestProperties()
+                               .forEach((headerName, headerValues) -> headers.put(headerName,
+                                               String.join(",", headerValues))); //$NON-NLS-1$
+
+               // add required content headers
+               if (contentLength > 0) {
+                       headers.put(HttpSupport.HDR_CONTENT_LENGTH,
+                                       String.valueOf(contentLength));
+               } else {
+                       bodyHash = EMPTY_BODY_SHA256;
+               }
+               headers.put("x-amz-content-sha256", bodyHash); //$NON-NLS-1$
+
+               // add the 'x-amz-date' header
+               OffsetDateTime now = Instant.now().atOffset(ZoneOffset.UTC);
+               String amzDate = now.format(AMZ_DATE_FORMAT);
+               headers.put("x-amz-date", amzDate); //$NON-NLS-1$
+
+               // add the 'host' header
+               URL endpointUrl = httpURLConnection.getURL();
+               int port = endpointUrl.getPort();
+               String hostHeader = (port > -1)
+                               ? endpointUrl.getHost().concat(":" + port) //$NON-NLS-1$
+                               : endpointUrl.getHost();
+               headers.put("Host", hostHeader); //$NON-NLS-1$
+
+               // construct the canonicalized request
+               String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers);
+               String canonicalizedHeaders = getCanonicalizedHeaderString(headers);
+               String canonicalizedQueryParameters = getCanonicalizedQueryString(
+                               queryParameters);
+               String httpMethod = httpURLConnection.getRequestMethod();
+               String canonicalRequest = httpMethod + '\n'
+                               + getCanonicalizedResourcePath(endpointUrl) + '\n'
+                               + canonicalizedQueryParameters + '\n' + canonicalizedHeaders
+                               + '\n' + canonicalizedHeaderNames + '\n' + bodyHash;
+
+               // construct the string-to-sign
+               String scopeDate = now.format(SCOPE_DATE_FORMAT);
+               String scope = scopeDate + '/' + regionName + '/' + serviceName + '/'
+                               + TERMINATOR;
+               String stringToSign = SCHEME + '-' + ALGORITHM + '\n' + amzDate + '\n'
+                               + scope + '\n' + Hex.toHexString(hash(
+                                               canonicalRequest.getBytes(StandardCharsets.UTF_8)));
+
+               // compute the signing key
+               byte[] secretKey = (SCHEME + new String(awsSecretKey)).getBytes();
+               byte[] dateKey = signStringWithKey(scopeDate, secretKey);
+               byte[] regionKey = signStringWithKey(regionName, dateKey);
+               byte[] serviceKey = signStringWithKey(serviceName, regionKey);
+               byte[] signingKey = signStringWithKey(TERMINATOR, serviceKey);
+               byte[] signature = signStringWithKey(stringToSign, signingKey);
+
+               // construct the authorization header
+               String credentialsAuthorizationHeader = "Credential=" + awsAccessKey //$NON-NLS-1$
+                               + '/' + scope;
+               String signedHeadersAuthorizationHeader = "SignedHeaders=" //$NON-NLS-1$
+                               + canonicalizedHeaderNames;
+               String signatureAuthorizationHeader = "Signature=" //$NON-NLS-1$
+                               + Hex.toHexString(signature);
+               String authorizationHeader = SCHEME + '-' + ALGORITHM + ' '
+                               + credentialsAuthorizationHeader + ", " //$NON-NLS-1$
+                               + signedHeadersAuthorizationHeader + ", " //$NON-NLS-1$
+                               + signatureAuthorizationHeader;
+
+               // Copy back the updated request headers
+               headers.forEach(httpURLConnection::setRequestProperty);
+
+               // Add the 'authorization' header
+               httpURLConnection.setRequestProperty(HttpSupport.HDR_AUTHORIZATION,
+                               authorizationHeader);
+       }
+
+       /**
+        * Calculates the hex-encoded SHA-256 hash of the provided byte array.
+        *
+        * @param data
+        *            Byte array to hash
+        *
+        * @return Hex-encoded SHA-256 hash of the provided byte array.
+        */
+       public static String calculateBodyHash(final byte[] data) {
+               return (data == null || data.length < 1) ? EMPTY_BODY_SHA256
+                               : Hex.toHexString(hash(data));
+       }
+
+       /**
+        * Construct a string listing all request headers in sorted case-insensitive
+        * order, separated by a ';'.
+        *
+        * @param headers
+        *            Map containing all request headers.
+        *
+        * @return String that lists all request headers in sorted case-insensitive
+        *         order, separated by a ';'.
+        */
+       private static String getCanonicalizeHeaderNames(
+                       Map<String, String> headers) {
+               return headers.keySet().stream().map(String::toLowerCase).sorted()
+                               .collect(Collectors.joining(";")); //$NON-NLS-1$
+       }
+
+       /**
+        * Constructs the canonical header string for a request.
+        *
+        * @param headers
+        *            Map containing all request headers.
+        *
+        * @return The canonical headers with values for the request.
+        */
+       private static String getCanonicalizedHeaderString(
+                       Map<String, String> headers) {
+               if (headers == null || headers.isEmpty()) {
+                       return ""; //$NON-NLS-1$
+               }
+               StringBuilder sb = new StringBuilder();
+               headers.keySet().stream().sorted(String.CASE_INSENSITIVE_ORDER)
+                               .forEach(key -> {
+                                       String header = key.toLowerCase().replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$
+                                       String value = headers.get(key).replaceAll("\\s+", " "); //$NON-NLS-1$ //$NON-NLS-2$
+                                       sb.append(header).append(':').append(value).append('\n');
+                               });
+               return sb.toString();
+       }
+
+       /**
+        * Constructs the canonicalized resource path for an AWS service endpoint.
+        *
+        * @param url
+        *            The AWS service endpoint URL, including the path to any
+        *            resource.
+        *
+        * @return The canonicalized resource path for the AWS service endpoint.
+        */
+       private static String getCanonicalizedResourcePath(URL url) {
+               if (url == null) {
+                       return "/"; //$NON-NLS-1$
+               }
+               String path = url.getPath();
+               if (path == null || path.isEmpty()) {
+                       return "/"; //$NON-NLS-1$
+               }
+               String encodedPath = HttpSupport.urlEncode(path, true);
+               if (encodedPath.startsWith("/")) { //$NON-NLS-1$
+                       return encodedPath;
+               }
+               return "/".concat(encodedPath); //$NON-NLS-1$
+       }
+
+       /**
+        * Constructs the canonicalized query string for a request.
+        *
+        * @param queryParameters
+        *            The query parameters in the request.
+        *
+        * @return The canonicalized query string for the request.
+        */
+       public static String getCanonicalizedQueryString(
+                       Map<String, String> queryParameters) {
+               if (queryParameters == null || queryParameters.isEmpty()) {
+                       return ""; //$NON-NLS-1$
+               }
+               return queryParameters
+                               .keySet().stream().sorted().map(
+                                               key -> HttpSupport.urlEncode(key, false) + '='
+                                                               + HttpSupport.urlEncode(
+                                                                               queryParameters.get(key), false))
+                               .collect(Collectors.joining("&")); //$NON-NLS-1$
+       }
+
+       /**
+        * Hashes the provided byte array using the SHA-256 algorithm.
+        *
+        * @param data
+        *            The byte array to hash.
+        *
+        * @return Hashed string contents of the provided byte array.
+        */
+       public static byte[] hash(byte[] data) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$
+                       md.update(data);
+                       return md.digest();
+               } catch (Exception e) {
+                       throw new RuntimeException(
+                                       JGitText.get().couldNotHashByteArrayWithSha256, e);
+               }
+       }
+
+       /**
+        * Signs the provided string data using the specified key.
+        *
+        * @param stringToSign
+        *            The string data to sign.
+        * @param key
+        *            The key material of the secret key.
+        *
+        * @return Signed string data.
+        */
+       private static byte[] signStringWithKey(String stringToSign, byte[] key) {
+               try {
+                       byte[] data = stringToSign.getBytes(StandardCharsets.UTF_8);
+                       Mac mac = Mac.getInstance(MAC_ALGORITHM);
+                       mac.init(new SecretKeySpec(key, MAC_ALGORITHM));
+                       return mac.doFinal(data);
+               } catch (Exception e) {
+                       throw new RuntimeException(JGitText.get().couldNotSignStringWithKey,
+                                       e);
+               }
+       }
+
+}
index 23a73faf8c2ed891a1bc99f2d1bb71b667b3ee60..663a3449e1115c06bb2612f8a179d9b474414eee 100644 (file)
@@ -185,6 +185,33 @@ public class HttpSupport {
                }
        }
 
+       /**
+        * Translates the provided URL into application/x-www-form-urlencoded
+        * format.
+        *
+        * @param url
+        *            The URL to translate.
+        * @param keepPathSlash
+        *            Whether or not to keep "/" in the URL (i.e. don't translate
+        *            them to "%2F").
+        *
+        * @return The translated URL.
+        * @since 5.13
+        */
+       public static String urlEncode(String url, boolean keepPathSlash) {
+               String encoded;
+               try {
+                       encoded = URLEncoder.encode(url, UTF_8.name());
+               } catch (UnsupportedEncodingException e) {
+                       throw new RuntimeException(JGitText.get().couldNotURLEncodeToUTF8,
+                                       e);
+               }
+               if (keepPathSlash) {
+                       encoded = encoded.replace("%2F", "/"); //$NON-NLS-1$ //$NON-NLS-2$
+               }
+               return encoded;
+       }
+
        /**
         * Get the HTTP response code from the request.
         * <p>