SignerV4.java

/*
 * Copyright (C) 2015, Matthias Sohn <matthias.sohn@sap.com>
 * Copyright (C) 2015, Sasa Zivkov <sasa.zivkov@sap.com> and others
 *
 * 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.lfs.server.s3;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;

import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.SimpleTimeZone;
import java.util.SortedMap;
import java.util.TreeMap;

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

import org.eclipse.jgit.lfs.lib.Constants;
import org.eclipse.jgit.lfs.server.internal.LfsServerText;

/**
 * Signing support for Amazon AWS signing V4
 * <p>
 * See
 * http://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
 */
class SignerV4 {
	static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; //$NON-NLS-1$

	private static final String ALGORITHM = "HMAC-SHA256"; //$NON-NLS-1$
	private static final String DATE_STRING_FORMAT = "yyyyMMdd"; //$NON-NLS-1$
	private static final String HEX = "0123456789abcdef"; //$NON-NLS-1$
	private static final String HMACSHA256 = "HmacSHA256"; //$NON-NLS-1$
	private static final String ISO8601_BASIC_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; //$NON-NLS-1$
	private static final String S3 = "s3"; //$NON-NLS-1$
	private static final String SCHEME = "AWS4"; //$NON-NLS-1$
	private static final String TERMINATOR = "aws4_request"; //$NON-NLS-1$
	private static final String UTC = "UTC"; //$NON-NLS-1$
	private static final String X_AMZ_ALGORITHM = "X-Amz-Algorithm"; //$NON-NLS-1$
	private static final String X_AMZ_CREDENTIAL = "X-Amz-Credential"; //$NON-NLS-1$
	private static final String X_AMZ_DATE = "X-Amz-Date"; //$NON-NLS-1$
	private static final String X_AMZ_SIGNATURE = "X-Amz-Signature"; //$NON-NLS-1$
	private static final String X_AMZ_SIGNED_HEADERS = "X-Amz-SignedHeaders"; //$NON-NLS-1$

	static final String X_AMZ_CONTENT_SHA256 = "x-amz-content-sha256"; //$NON-NLS-1$
	static final String X_AMZ_EXPIRES = "X-Amz-Expires"; //$NON-NLS-1$
	static final String X_AMZ_STORAGE_CLASS = "x-amz-storage-class"; //$NON-NLS-1$

	/**
	 * Create an AWSV4 authorization for a request, suitable for embedding in
	 * query parameters.
	 *
	 * @param bucketConfig
	 *            configuration of S3 storage bucket this request should be
	 *            signed for
	 * @param url
	 *            HTTP request URL
	 * @param httpMethod
	 *            HTTP method
	 * @param headers
	 *            The HTTP request headers; 'Host' and 'X-Amz-Date' will be
	 *            added to this set.
	 * @param queryParameters
	 *            Any query parameters that will be added to the endpoint. The
	 *            parameters should be specified in canonical format.
	 * @param bodyHash
	 *            Pre-computed SHA256 hash of the request body content; this
	 *            value should also be set as the header 'X-Amz-Content-SHA256'
	 *            for non-streaming uploads.
	 * @return The computed authorization string for the request. This value
	 *         needs to be set as the header 'Authorization' on the subsequent
	 *         HTTP request.
	 */
	static String createAuthorizationQuery(S3Config bucketConfig, URL url,
			String httpMethod, Map<String, String> headers,
			Map<String, String> queryParameters, String bodyHash) {
		addHostHeader(url, headers);

		queryParameters.put(X_AMZ_ALGORITHM, SCHEME + "-" + ALGORITHM); //$NON-NLS-1$

		Date now = new Date();
		String dateStamp = dateStamp(now);
		String scope = scope(bucketConfig.getRegion(), dateStamp);
		queryParameters.put(X_AMZ_CREDENTIAL,
				bucketConfig.getAccessKey() + "/" + scope); //$NON-NLS-1$

		String dateTimeStampISO8601 = dateTimeStampISO8601(now);
		queryParameters.put(X_AMZ_DATE, dateTimeStampISO8601);

		String canonicalizedHeaderNames = canonicalizeHeaderNames(headers);
		queryParameters.put(X_AMZ_SIGNED_HEADERS, canonicalizedHeaderNames);

		String canonicalizedQueryParameters = canonicalizeQueryString(
				queryParameters);
		String canonicalizedHeaders = canonicalizeHeaderString(headers);
		String canonicalRequest = canonicalRequest(url, httpMethod,
				canonicalizedQueryParameters, canonicalizedHeaderNames,
				canonicalizedHeaders, bodyHash);
		byte[] signature = createSignature(bucketConfig, dateTimeStampISO8601,
				dateStamp, scope, canonicalRequest);
		queryParameters.put(X_AMZ_SIGNATURE, toHex(signature));

		return formatAuthorizationQuery(queryParameters);
	}

	private static String formatAuthorizationQuery(
			Map<String, String> queryParameters) {
		StringBuilder s = new StringBuilder();
		for (String key : queryParameters.keySet()) {
			appendQuery(s, key, queryParameters.get(key));
		}
		return s.toString();
	}

	private static void appendQuery(StringBuilder s, String key,
			String value) {
		if (s.length() != 0) {
			s.append("&"); //$NON-NLS-1$
		}
		s.append(key).append("=").append(value); //$NON-NLS-1$
	}

	/**
	 * Sign headers for given bucket, url and HTTP method and add signature in
	 * Authorization header.
	 *
	 * @param bucketConfig
	 *            configuration of S3 storage bucket this request should be
	 *            signed for
	 * @param url
	 *            HTTP request URL
	 * @param httpMethod
	 *            HTTP method
	 * @param headers
	 *            HTTP headers to sign
	 * @param bodyHash
	 *            Pre-computed SHA256 hash of the request body content; this
	 *            value should also be set as the header 'X-Amz-Content-SHA256'
	 *            for non-streaming uploads.
	 * @return HTTP headers signd by an Authorization header added to the
	 *         headers
	 */
	static Map<String, String> createHeaderAuthorization(
			S3Config bucketConfig, URL url, String httpMethod,
			Map<String, String> headers, String bodyHash) {
		addHostHeader(url, headers);

		Date now = new Date();
		String dateTimeStamp = dateTimeStampISO8601(now);
		headers.put(X_AMZ_DATE, dateTimeStamp);

		String canonicalizedHeaderNames = canonicalizeHeaderNames(headers);
		String canonicalizedHeaders = canonicalizeHeaderString(headers);
		String canonicalRequest = canonicalRequest(url, httpMethod, "", //$NON-NLS-1$
				canonicalizedHeaderNames, canonicalizedHeaders, bodyHash);
		String dateStamp = dateStamp(now);
		String scope = scope(bucketConfig.getRegion(), dateStamp);

		byte[] signature = createSignature(bucketConfig, dateTimeStamp,
				dateStamp, scope, canonicalRequest);

		headers.put(HDR_AUTHORIZATION, formatAuthorizationHeader(bucketConfig,
				canonicalizedHeaderNames, scope, signature)); // $NON-NLS-1$

		return headers;
	}

	private static String formatAuthorizationHeader(
			S3Config bucketConfig, String canonicalizedHeaderNames,
			String scope, byte[] signature) {
		StringBuilder s = new StringBuilder();
		s.append(SCHEME).append("-").append(ALGORITHM).append(" "); //$NON-NLS-1$ //$NON-NLS-2$
		s.append("Credential=").append(bucketConfig.getAccessKey()).append("/") //$NON-NLS-1$//$NON-NLS-2$
				.append(scope).append(","); //$NON-NLS-1$
		s.append("SignedHeaders=").append(canonicalizedHeaderNames).append(","); //$NON-NLS-1$ //$NON-NLS-2$
		s.append("Signature=").append(toHex(signature)); //$NON-NLS-1$
		return s.toString();
	}

	private static void addHostHeader(URL url,
			Map<String, String> headers) {
		StringBuilder hostHeader = new StringBuilder(url.getHost());
		int port = url.getPort();
		if (port > -1) {
			hostHeader.append(":").append(port); //$NON-NLS-1$
		}
		headers.put("Host", hostHeader.toString()); //$NON-NLS-1$
	}

	private static String canonicalizeHeaderNames(
			Map<String, String> headers) {
		List<String> sortedHeaders = new ArrayList<>();
		sortedHeaders.addAll(headers.keySet());
		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);

		StringBuilder buffer = new StringBuilder();
		for (String header : sortedHeaders) {
			if (buffer.length() > 0)
				buffer.append(";"); //$NON-NLS-1$
			buffer.append(header.toLowerCase(Locale.ROOT));
		}

		return buffer.toString();
	}

	private static String canonicalizeHeaderString(
			Map<String, String> headers) {
		if (headers == null || headers.isEmpty()) {
			return ""; //$NON-NLS-1$
		}

		List<String> sortedHeaders = new ArrayList<>();
		sortedHeaders.addAll(headers.keySet());
		Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);

		StringBuilder buffer = new StringBuilder();
		for (String key : sortedHeaders) {
			buffer.append(
					key.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ") + ":" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
					+ headers.get(key).replaceAll("\\s+", " ")); //$NON-NLS-1$//$NON-NLS-2$
			buffer.append("\n"); //$NON-NLS-1$
		}

		return buffer.toString();
	}

	private static String dateStamp(Date now) {
		// TODO(ms) cache and reuse DateFormat instances
		SimpleDateFormat dateStampFormat = new SimpleDateFormat(
				DATE_STRING_FORMAT);
		dateStampFormat.setTimeZone(new SimpleTimeZone(0, UTC));
		String dateStamp = dateStampFormat.format(now);
		return dateStamp;
	}

	private static String dateTimeStampISO8601(Date now) {
		// TODO(ms) cache and reuse DateFormat instances
		SimpleDateFormat dateTimeFormat = new SimpleDateFormat(
				ISO8601_BASIC_FORMAT);
		dateTimeFormat.setTimeZone(new SimpleTimeZone(0, UTC));
		String dateTimeStamp = dateTimeFormat.format(now);
		return dateTimeStamp;
	}

	private static String scope(String region, String dateStamp) {
		String scope = String.format("%s/%s/%s/%s", dateStamp, region, S3, //$NON-NLS-1$
				TERMINATOR);
		return scope;
	}

	private static String canonicalizeQueryString(
			Map<String, String> parameters) {
		if (parameters == null || parameters.isEmpty()) {
			return ""; //$NON-NLS-1$
		}

		SortedMap<String, String> sorted = new TreeMap<>();

		Iterator<Map.Entry<String, String>> pairs = parameters.entrySet()
				.iterator();
		while (pairs.hasNext()) {
			Map.Entry<String, String> pair = pairs.next();
			String key = pair.getKey();
			String value = pair.getValue();
			sorted.put(urlEncode(key, false), urlEncode(value, false));
		}

		StringBuilder builder = new StringBuilder();
		pairs = sorted.entrySet().iterator();
		while (pairs.hasNext()) {
			Map.Entry<String, String> pair = pairs.next();
			builder.append(pair.getKey());
			builder.append("="); //$NON-NLS-1$
			builder.append(pair.getValue());
			if (pairs.hasNext()) {
				builder.append("&"); //$NON-NLS-1$
			}
		}

		return builder.toString();
	}

	private static String canonicalRequest(URL endpoint, String httpMethod,
			String queryParameters, String canonicalizedHeaderNames,
			String canonicalizedHeaders, String bodyHash) {
		return String.format("%s\n%s\n%s\n%s\n%s\n%s", //$NON-NLS-1$
				httpMethod, canonicalizeResourcePath(endpoint),
				queryParameters, canonicalizedHeaders, canonicalizedHeaderNames,
				bodyHash);
	}

	private static String canonicalizeResourcePath(URL endpoint) {
		if (endpoint == null) {
			return "/"; //$NON-NLS-1$
		}
		String path = endpoint.getPath();
		if (path == null || path.isEmpty()) {
			return "/"; //$NON-NLS-1$
		}

		String encodedPath = urlEncode(path, true);
		if (encodedPath.startsWith("/")) { //$NON-NLS-1$
			return encodedPath;
		}
		return "/" + encodedPath; //$NON-NLS-1$
	}

	private static byte[] hash(String s) {
		MessageDigest md = Constants.newMessageDigest();
		md.update(s.getBytes(UTF_8));
		return md.digest();
	}

	private static byte[] sign(String stringData, byte[] key) {
		try {
			byte[] data = stringData.getBytes(UTF_8);
			Mac mac = Mac.getInstance(HMACSHA256);
			mac.init(new SecretKeySpec(key, HMACSHA256));
			return mac.doFinal(data);
		} catch (Exception e) {
			throw new RuntimeException(MessageFormat.format(
					LfsServerText.get().failedToCalcSignature, e.getMessage()),
					e);
		}
	}

	private static String stringToSign(String scheme, String algorithm,
			String dateTime, String scope, String canonicalRequest) {
		return String.format("%s-%s\n%s\n%s\n%s", //$NON-NLS-1$
				scheme, algorithm, dateTime, scope,
				toHex(hash(canonicalRequest)));
	}

	private static String toHex(byte[] bytes) {
		StringBuilder builder = new StringBuilder(2 * bytes.length);
		for (byte b : bytes) {
			builder.append(HEX.charAt((b & 0xF0) >> 4));
			builder.append(HEX.charAt(b & 0xF));
		}
		return builder.toString();
	}

	private static String urlEncode(String url, boolean keepPathSlash) {
		String encoded;
		try {
			encoded = URLEncoder.encode(url, UTF_8.name());
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(LfsServerText.get().unsupportedUtf8, e);
		}
		if (keepPathSlash) {
			encoded = encoded.replace("%2F", "/"); //$NON-NLS-1$ //$NON-NLS-2$
		}
		return encoded;
	}

	private static byte[] createSignature(S3Config bucketConfig,
			String dateTimeStamp, String dateStamp,
			String scope, String canonicalRequest) {
		String stringToSign = stringToSign(SCHEME, ALGORITHM, dateTimeStamp,
				scope, canonicalRequest);

		byte[] signature = (SCHEME + bucketConfig.getSecretKey())
				.getBytes(UTF_8);
		signature = sign(dateStamp, signature);
		signature = sign(bucketConfig.getRegion(), signature);
		signature = sign(S3, signature);
		signature = sign(TERMINATOR, signature);
		signature = sign(stringToSign, signature);
		return signature;
	}
}