From e9a5430c2557778bc6c43986527d57023090e781 Mon Sep 17 00:00:00 2001 From: "eric.steele" Date: Tue, 31 May 2022 18:03:17 -0700 Subject: [PATCH] AmazonS3: Add support for AWS API signature version 4 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 --- .../jgit-s3-config.disabled.properties | 9 + .../eclipse/jgit/internal/JGitText.properties | 5 + .../org/eclipse/jgit/internal/JGitText.java | 5 + .../org/eclipse/jgit/transport/AmazonS3.java | 94 +++++- .../jgit/transport/AwsRequestSignerV4.java | 313 ++++++++++++++++++ .../org/eclipse/jgit/util/HttpSupport.java | 27 ++ 6 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/transport/AwsRequestSignerV4.java diff --git a/org.eclipse.jgit.test/tst-rsrc/jgit-s3-config.disabled.properties b/org.eclipse.jgit.test/tst-rsrc/jgit-s3-config.disabled.properties index d540977e9..3f36282b9 100644 --- a/org.eclipse.jgit.test/tst-rsrc/jgit-s3-config.disabled.properties +++ b/org.eclipse.jgit.test/tst-rsrc/jgit-s3-config.disabled.properties @@ -40,6 +40,15 @@ # * 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 diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties index 3acceab09..3c0f75710 100644 --- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties +++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties @@ -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 diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java index 76340dabb..2b48bf5a1 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java @@ -238,12 +238,14 @@ public static JGitText get() { /***/ 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 static JGitText get() { /***/ 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 static JGitText get() { /***/ 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 static JGitText get() { /***/ public String unableToSignCommitNoSecretKey; /***/ public String unauthorized; /***/ public String unencodeableFile; + /***/ public String unexpectedAwsApiSignatureVersion; /***/ public String unexpectedCompareResult; /***/ public String unexpectedEndOfConfigFile; /***/ public String unexpectedEndOfInput; diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/AmazonS3.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/AmazonS3.java index 9210ec172..3e5af76f8 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/AmazonS3.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/AmazonS3.java @@ -31,6 +31,7 @@ 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.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 @@ public class AmazonS3 { private static final Set 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 @@ private static MessageDigest newMD5() { } } + /** 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 @@ private static MessageDigest newMD5() { /** 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 @@ interface Keys { 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 @@ interface Keys { * For example: * *
+	 * # 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: <YourAWSAccessKey>
 	 * secretkey: <YourAWSSecretKey>
@@ -191,6 +214,9 @@ interface Keys {
 	 * # 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 @@ interface Keys {
 	 *            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 URLConnection get(String bucket, String key)
 			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 void delete(String bucket, String key)
 			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 void put(String bucket, String key, byte[] data)
 		}
 
 		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 @@ void putImpl(final String bucket, final String key,
 			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 @@ void putImpl(final String bucket, final String key,
 			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 @@ HttpURLConnection open(final String method, final String bucket,
 		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> i;
 
@@ -572,7 +627,18 @@ HttpURLConnection open(final String method, final String bucket,
 		return c;
 	}
 
-	void authorize(HttpURLConnection c) throws IOException {
+	void authorize(HttpURLConnection httpURLConnection,
+			Map 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> reqHdr = c.getRequestProperties();
 		final SortedMap sigHdr = new TreeMap<>();
 		for (Map.Entry> entry : reqHdr.entrySet()) {
@@ -609,7 +675,7 @@ void authorize(HttpURLConnection c) throws IOException {
 		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 @@ void list() throws IOException {
 
 			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
index 000000000..6b3d39721
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/AwsRequestSignerV4.java
@@ -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: AWSS3SigV4JavaSamples.zip
+ *
+ * @see AWS
+ *      Signature Version 4
+ *
+ * @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 queryParameters, long contentLength,
+			String bodyHash, String serviceName, String regionName,
+			String awsAccessKey, char[] awsSecretKey) {
+		// get request headers
+		Map 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 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 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 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);
+		}
+	}
+
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/HttpSupport.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/HttpSupport.java
index 23a73faf8..663a3449e 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/util/HttpSupport.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/HttpSupport.java
@@ -185,6 +185,33 @@ public static void encode(StringBuilder urlstr, String key) {
 		}
 	}
 
+	/**
+	 * 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.
 	 *