AwsRequestSignerV4.java

/*
 * 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.1
 */
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);
		}
	}

}