SignerV4.java

  1. /*
  2.  * Copyright (C) 2015, Matthias Sohn <matthias.sohn@sap.com>
  3.  * Copyright (C) 2015, Sasa Zivkov <sasa.zivkov@sap.com> and others
  4.  *
  5.  * This program and the accompanying materials are made available under the
  6.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  7.  * https://www.eclipse.org/org/documents/edl-v10.php.
  8.  *
  9.  * SPDX-License-Identifier: BSD-3-Clause
  10.  */
  11. package org.eclipse.jgit.lfs.server.s3;

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

  14. import java.io.UnsupportedEncodingException;
  15. import java.net.URL;
  16. import java.net.URLEncoder;
  17. import java.security.MessageDigest;
  18. import java.text.MessageFormat;
  19. import java.text.SimpleDateFormat;
  20. import java.util.ArrayList;
  21. import java.util.Collections;
  22. import java.util.Date;
  23. import java.util.Iterator;
  24. import java.util.List;
  25. import java.util.Locale;
  26. import java.util.Map;
  27. import java.util.SimpleTimeZone;
  28. import java.util.SortedMap;
  29. import java.util.TreeMap;

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

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

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

  42.     private static final String ALGORITHM = "HMAC-SHA256"; //$NON-NLS-1$
  43.     private static final String DATE_STRING_FORMAT = "yyyyMMdd"; //$NON-NLS-1$
  44.     private static final String HEX = "0123456789abcdef"; //$NON-NLS-1$
  45.     private static final String HMACSHA256 = "HmacSHA256"; //$NON-NLS-1$
  46.     private static final String ISO8601_BASIC_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; //$NON-NLS-1$
  47.     private static final String S3 = "s3"; //$NON-NLS-1$
  48.     private static final String SCHEME = "AWS4"; //$NON-NLS-1$
  49.     private static final String TERMINATOR = "aws4_request"; //$NON-NLS-1$
  50.     private static final String UTC = "UTC"; //$NON-NLS-1$
  51.     private static final String X_AMZ_ALGORITHM = "X-Amz-Algorithm"; //$NON-NLS-1$
  52.     private static final String X_AMZ_CREDENTIAL = "X-Amz-Credential"; //$NON-NLS-1$
  53.     private static final String X_AMZ_DATE = "X-Amz-Date"; //$NON-NLS-1$
  54.     private static final String X_AMZ_SIGNATURE = "X-Amz-Signature"; //$NON-NLS-1$
  55.     private static final String X_AMZ_SIGNED_HEADERS = "X-Amz-SignedHeaders"; //$NON-NLS-1$

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

  59.     /**
  60.      * Create an AWSV4 authorization for a request, suitable for embedding in
  61.      * query parameters.
  62.      *
  63.      * @param bucketConfig
  64.      *            configuration of S3 storage bucket this request should be
  65.      *            signed for
  66.      * @param url
  67.      *            HTTP request URL
  68.      * @param httpMethod
  69.      *            HTTP method
  70.      * @param headers
  71.      *            The HTTP request headers; 'Host' and 'X-Amz-Date' will be
  72.      *            added to this set.
  73.      * @param queryParameters
  74.      *            Any query parameters that will be added to the endpoint. The
  75.      *            parameters should be specified in canonical format.
  76.      * @param bodyHash
  77.      *            Pre-computed SHA256 hash of the request body content; this
  78.      *            value should also be set as the header 'X-Amz-Content-SHA256'
  79.      *            for non-streaming uploads.
  80.      * @return The computed authorization string for the request. This value
  81.      *         needs to be set as the header 'Authorization' on the subsequent
  82.      *         HTTP request.
  83.      */
  84.     static String createAuthorizationQuery(S3Config bucketConfig, URL url,
  85.             String httpMethod, Map<String, String> headers,
  86.             Map<String, String> queryParameters, String bodyHash) {
  87.         addHostHeader(url, headers);

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

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

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

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

  98.         String canonicalizedQueryParameters = canonicalizeQueryString(
  99.                 queryParameters);
  100.         String canonicalizedHeaders = canonicalizeHeaderString(headers);
  101.         String canonicalRequest = canonicalRequest(url, httpMethod,
  102.                 canonicalizedQueryParameters, canonicalizedHeaderNames,
  103.                 canonicalizedHeaders, bodyHash);
  104.         byte[] signature = createSignature(bucketConfig, dateTimeStampISO8601,
  105.                 dateStamp, scope, canonicalRequest);
  106.         queryParameters.put(X_AMZ_SIGNATURE, toHex(signature));

  107.         return formatAuthorizationQuery(queryParameters);
  108.     }

  109.     private static String formatAuthorizationQuery(
  110.             Map<String, String> queryParameters) {
  111.         StringBuilder s = new StringBuilder();
  112.         for (String key : queryParameters.keySet()) {
  113.             appendQuery(s, key, queryParameters.get(key));
  114.         }
  115.         return s.toString();
  116.     }

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

  124.     /**
  125.      * Sign headers for given bucket, url and HTTP method and add signature in
  126.      * Authorization header.
  127.      *
  128.      * @param bucketConfig
  129.      *            configuration of S3 storage bucket this request should be
  130.      *            signed for
  131.      * @param url
  132.      *            HTTP request URL
  133.      * @param httpMethod
  134.      *            HTTP method
  135.      * @param headers
  136.      *            HTTP headers to sign
  137.      * @param bodyHash
  138.      *            Pre-computed SHA256 hash of the request body content; this
  139.      *            value should also be set as the header 'X-Amz-Content-SHA256'
  140.      *            for non-streaming uploads.
  141.      * @return HTTP headers signd by an Authorization header added to the
  142.      *         headers
  143.      */
  144.     static Map<String, String> createHeaderAuthorization(
  145.             S3Config bucketConfig, URL url, String httpMethod,
  146.             Map<String, String> headers, String bodyHash) {
  147.         addHostHeader(url, headers);

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

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

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

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

  161.         return headers;
  162.     }

  163.     private static String formatAuthorizationHeader(
  164.             S3Config bucketConfig, String canonicalizedHeaderNames,
  165.             String scope, byte[] signature) {
  166.         StringBuilder s = new StringBuilder();
  167.         s.append(SCHEME).append("-").append(ALGORITHM).append(" "); //$NON-NLS-1$ //$NON-NLS-2$
  168.         s.append("Credential=").append(bucketConfig.getAccessKey()).append("/") //$NON-NLS-1$//$NON-NLS-2$
  169.                 .append(scope).append(","); //$NON-NLS-1$
  170.         s.append("SignedHeaders=").append(canonicalizedHeaderNames).append(","); //$NON-NLS-1$ //$NON-NLS-2$
  171.         s.append("Signature=").append(toHex(signature)); //$NON-NLS-1$
  172.         return s.toString();
  173.     }

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

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

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

  194.         return buffer.toString();
  195.     }

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

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

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

  211.         return buffer.toString();
  212.     }

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

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

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

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

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

  240.         Iterator<Map.Entry<String, String>> pairs = parameters.entrySet()
  241.                 .iterator();
  242.         while (pairs.hasNext()) {
  243.             Map.Entry<String, String> pair = pairs.next();
  244.             String key = pair.getKey();
  245.             String value = pair.getValue();
  246.             sorted.put(urlEncode(key, false), urlEncode(value, false));
  247.         }

  248.         StringBuilder builder = new StringBuilder();
  249.         pairs = sorted.entrySet().iterator();
  250.         while (pairs.hasNext()) {
  251.             Map.Entry<String, String> pair = pairs.next();
  252.             builder.append(pair.getKey());
  253.             builder.append("="); //$NON-NLS-1$
  254.             builder.append(pair.getValue());
  255.             if (pairs.hasNext()) {
  256.                 builder.append("&"); //$NON-NLS-1$
  257.             }
  258.         }

  259.         return builder.toString();
  260.     }

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

  269.     private static String canonicalizeResourcePath(URL endpoint) {
  270.         if (endpoint == null) {
  271.             return "/"; //$NON-NLS-1$
  272.         }
  273.         String path = endpoint.getPath();
  274.         if (path == null || path.isEmpty()) {
  275.             return "/"; //$NON-NLS-1$
  276.         }

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

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

  288.     private static byte[] sign(String stringData, byte[] key) {
  289.         try {
  290.             byte[] data = stringData.getBytes(UTF_8);
  291.             Mac mac = Mac.getInstance(HMACSHA256);
  292.             mac.init(new SecretKeySpec(key, HMACSHA256));
  293.             return mac.doFinal(data);
  294.         } catch (Exception e) {
  295.             throw new RuntimeException(MessageFormat.format(
  296.                     LfsServerText.get().failedToCalcSignature, e.getMessage()),
  297.                     e);
  298.         }
  299.     }

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

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

  314.     private static String urlEncode(String url, boolean keepPathSlash) {
  315.         String encoded;
  316.         try {
  317.             encoded = URLEncoder.encode(url, UTF_8.name());
  318.         } catch (UnsupportedEncodingException e) {
  319.             throw new RuntimeException(LfsServerText.get().unsupportedUtf8, e);
  320.         }
  321.         if (keepPathSlash) {
  322.             encoded = encoded.replace("%2F", "/"); //$NON-NLS-1$ //$NON-NLS-2$
  323.         }
  324.         return encoded;
  325.     }

  326.     private static byte[] createSignature(S3Config bucketConfig,
  327.             String dateTimeStamp, String dateStamp,
  328.             String scope, String canonicalRequest) {
  329.         String stringToSign = stringToSign(SCHEME, ALGORITHM, dateTimeStamp,
  330.                 scope, canonicalRequest);

  331.         byte[] signature = (SCHEME + bucketConfig.getSecretKey())
  332.                 .getBytes(UTF_8);
  333.         signature = sign(dateStamp, signature);
  334.         signature = sign(bucketConfig.getRegion(), signature);
  335.         signature = sign(S3, signature);
  336.         signature = sign(TERMINATOR, signature);
  337.         signature = sign(stringToSign, signature);
  338.         return signature;
  339.     }
  340. }