/* * 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 static java.nio.charset.StandardCharsets.UTF_8; 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.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 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(UTF_8); 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); } } }