github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/clients/hadoopfs/src/main/java/io/lakefs/auth/GetCallerIdentityV4Presigner.java (about)

     1  package io.lakefs.auth;
     2  
     3  import static com.amazonaws.util.StringUtils.UTF8;
     4  
     5  import java.io.UnsupportedEncodingException;
     6  import java.net.URI;
     7  import java.net.URLEncoder;
     8  import java.security.MessageDigest;
     9  import java.time.Instant;
    10  import java.time.ZoneId;
    11  import java.time.format.DateTimeFormatter;
    12  import java.util.*;
    13  import java.util.regex.Matcher;
    14  import java.util.regex.Pattern;
    15  
    16  import javax.crypto.Mac;
    17  import javax.crypto.spec.SecretKeySpec;
    18  
    19  import com.amazonaws.AmazonClientException;
    20  import com.amazonaws.auth.*;
    21  import com.amazonaws.util.AwsHostNameUtils;
    22  import com.amazonaws.util.BinaryUtils;
    23  import org.slf4j.Logger;
    24  import org.slf4j.LoggerFactory;
    25  
    26  /**
    27   *  * GetCallerIdentityV4Presigner generates a presigned URL for the GetCallerIdentity API, signed using SigV4.
    28   *  * This class extends AWS4Signer of AWS SDK version 1.7.4 and copies some functions from https://github.com/aws/aws-sdk-java/blob/1.7.4/src/main/java/com/amazonaws/auth/AWS4Signer.java
    29   *    * The copied functions exist in AWS SDK 1.7.4 but not AWS SDK 1.11.375, so they are
    30   *      not available on Hadoop AWS 3.
    31   */
    32  public class GetCallerIdentityV4Presigner implements STSGetCallerIdentityPresigner {
    33      private static final String DEFAULT_ENCODING = "UTF-8";
    34      private static final String TERMINATOR = "aws4_request";
    35      private static final String ALGORITHM = "AWS4-HMAC-SHA256";
    36      private static final String SERVICE_NAME = "sts";
    37      private static final Logger LOG = LoggerFactory.getLogger(GetCallerIdentityV4Presigner.class);
    38      // source at https://github.com/aws/aws-sdk-java/blob/679abaebd371b09e887afaa5386dc182be4c6498/aws-java-sdk-core/src/main/java/com/amazonaws/util/SdkHttpUtils.java#L39
    39      /**
    40       * Regex which matches any of the sequences that we need to fix up after
    41       * URLEncoder.encode().
    42       */
    43      private static final Pattern ENCODED_CHARACTERS_PATTERN;
    44  
    45      static {
    46          StringBuilder pattern = new StringBuilder();
    47  
    48          pattern.append(Pattern.quote("+")).append("|").append(Pattern.quote("*")).append("|").append(Pattern.quote("%7E")).append("|").append(Pattern.quote("%2F"));
    49  
    50          ENCODED_CHARACTERS_PATTERN = Pattern.compile(pattern.toString());
    51      }
    52  
    53      public GetCallerIdentityV4Presigner() {
    54      }
    55  
    56      public byte[] sign(String stringData, byte[] key, SigningAlgorithm algorithm) throws AmazonClientException {
    57          try {
    58              byte[] data = stringData.getBytes(UTF8);
    59              return sign(data, key, algorithm);
    60          } catch (Exception e) {
    61              throw new AmazonClientException("Unable to calculate a request signature: " + e.getMessage(), e);
    62          }
    63      }
    64  
    65      protected byte[] sign(byte[] data, byte[] key, SigningAlgorithm algorithm) throws AmazonClientException {
    66          try {
    67              Mac mac = Mac.getInstance(algorithm.toString());
    68              mac.init(new SecretKeySpec(key, algorithm.toString()));
    69              return mac.doFinal(data);
    70          } catch (Exception e) {
    71              throw new AmazonClientException("Unable to calculate a request signature: " + e.getMessage(), e);
    72          }
    73      }
    74  
    75      /**
    76       * Hashes the string contents (assumed to be UTF-8) using the SHA-256
    77       * algorithm.
    78       *
    79       * @param text The string to hash.
    80       * @return The hashed bytes from the specified string.
    81       * @throws AmazonClientException If the hash cannot be computed.
    82       */
    83      public byte[] hash(String text) throws AmazonClientException {
    84          try {
    85              MessageDigest md = MessageDigest.getInstance("SHA-256");
    86              md.update(text.getBytes(UTF8));
    87              return md.digest();
    88          } catch (Exception e) {
    89              throw new AmazonClientException("Unable to compute hash while signing request: " + e.getMessage(), e);
    90          }
    91      }
    92  
    93      /**
    94       * Examines the specified query string parameters and returns a
    95       * canonicalized form.
    96       * <p>
    97       * The canonicalized query string is formed by first sorting all the query
    98       * string parameters, then URI encoding both the key and value and then
    99       * joining them, in order, separating key value pairs with an '&'.
   100       *
   101       * @param parameters The query string parameters to be canonicalized.
   102       * @return A canonicalized form for the specified query string parameters.
   103       */
   104      protected String getCanonicalizedQueryString(Map<String, String> parameters) {
   105  
   106          SortedMap<String, String> sorted = new TreeMap<String, String>();
   107  
   108          Iterator<Map.Entry<String, String>> pairs = parameters.entrySet().iterator();
   109          while (pairs.hasNext()) {
   110              Map.Entry<String, String> pair = pairs.next();
   111              String key = pair.getKey();
   112              String value = pair.getValue();
   113              String encodedKey = urlEncode(key, false);
   114              String encodedValue = urlEncode(value, false);
   115              sorted.put(encodedKey, encodedValue);
   116  
   117          }
   118  
   119          StringBuilder builder = new StringBuilder();
   120          pairs = sorted.entrySet().iterator();
   121          while (pairs.hasNext()) {
   122              Map.Entry<String, String> pair = pairs.next();
   123              builder.append(pair.getKey());
   124              builder.append("=");
   125              builder.append(pair.getValue());
   126              if (pairs.hasNext()) {
   127                  builder.append("&");
   128              }
   129          }
   130  
   131          return builder.toString();
   132      }
   133  
   134      /**
   135       * Loads the individual access key ID and secret key from the specified
   136       * credentials, ensuring that access to the credentials is synchronized on
   137       * the credentials object itself, and trimming any extra whitespace from the
   138       * credentials.
   139       * <p>
   140       * Returns either a {@link BasicSessionCredentials} or a
   141       * {@link BasicAWSCredentials} object, depending on the input type.
   142       *
   143       * @param credentials
   144       * @return A new credentials object with the sanitized credentials.
   145       */
   146      protected AWSCredentials sanitizeCredentials(AWSCredentials credentials) {
   147          String accessKeyId = null;
   148          String secretKey = null;
   149          String token = null;
   150          synchronized (credentials) {
   151              accessKeyId = credentials.getAWSAccessKeyId();
   152              secretKey = credentials.getAWSSecretKey();
   153              if (credentials instanceof AWSSessionCredentials) {
   154                  token = ((AWSSessionCredentials) credentials).getSessionToken();
   155              }
   156          }
   157          if (secretKey != null) secretKey = secretKey.trim();
   158          if (accessKeyId != null) accessKeyId = accessKeyId.trim();
   159          if (token != null) token = token.trim();
   160  
   161          if (credentials instanceof AWSSessionCredentials) {
   162              return new BasicSessionCredentials(accessKeyId, secretKey, token);
   163          }
   164  
   165          return new BasicAWSCredentials(accessKeyId, secretKey);
   166      }
   167  
   168      protected Date getSignatureDate(int timeOffset) {
   169          Date dateValue = new Date();
   170          if (timeOffset != 0) {
   171              long epochMillis = dateValue.getTime();
   172              epochMillis -= timeOffset * 1000;
   173              dateValue = new Date(epochMillis);
   174          }
   175          return dateValue;
   176      }
   177  
   178      public static String getDateStamp(long dateMilli) {
   179  
   180          // Convert milliseconds to Instant
   181          Instant instant = Instant.ofEpochMilli(dateMilli);
   182  
   183          // Format Instant to String in UTC time zone
   184          DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneId.of("UTC"));
   185          String formattedDate = formatter.format(instant);
   186  
   187          return formattedDate;
   188      }
   189  
   190      public static String getTimeStamp(long dateMilli) {
   191          // Convert milliseconds to Instant
   192          Instant instant = Instant.ofEpochMilli(dateMilli);
   193  
   194          // Format Instant to String in UTC time zone
   195          DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneId.of("UTC"));
   196          String formattedDateTime = formatter.format(instant);
   197          return formattedDateTime;
   198      }
   199  
   200      // source at https://github.com/aws/aws-sdk-java/blob/d1790c78af50488f38d758fd1f654d035b505150/src/main/java/com/amazonaws/auth/AWS4Signer.java
   201      protected String getCanonicalizedHeaderString(Map<String, String> requestHeaders) {
   202          List<String> sortedHeaders = new ArrayList<>();
   203          sortedHeaders.addAll(requestHeaders.keySet());
   204          Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
   205  
   206          StringBuilder buffer = new StringBuilder();
   207          for (String header : sortedHeaders) {
   208              String key = header.toLowerCase().replaceAll("\\s+", " ");
   209              String value = requestHeaders.get(header);
   210  
   211              buffer.append(key).append(":");
   212              if (value != null) {
   213                  buffer.append(value.replaceAll("\\s+", " "));
   214              }
   215  
   216              buffer.append("\n");
   217          }
   218  
   219          return buffer.toString();
   220      }
   221  
   222      // source at https://github.com/aws/aws-sdk-java/blob/d1790c78af50488f38d758fd1f654d035b505150/src/main/java/com/amazonaws/auth/AWS4Signer.java
   223      protected String getSignedHeadersString(Map<String, String> headers) {
   224          List<String> sortedHeaders = new ArrayList<>();
   225          sortedHeaders.addAll(headers.keySet());
   226          Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER);
   227  
   228          StringBuilder buffer = new StringBuilder();
   229          for (String header : sortedHeaders) {
   230              if (buffer.length() > 0) buffer.append(";");
   231              buffer.append(header.toLowerCase());
   232          }
   233  
   234          return buffer.toString();
   235      }
   236      // source at https://github.com/aws/aws-sdk-java/blob/d1790c78af50488f38d758fd1f654d035b505150/src/main/java/com/amazonaws/auth/AWS4Signer.java#L241
   237      protected String getCanonicalRequest(String httpMethod, Map<String, String> parameters, Map<String, String> headers, String contentSha256) {
   238          final String path = "/";
   239          String canonicalRequest = httpMethod + "\n" + path + "\n" + getCanonicalizedQueryString(parameters) + "\n" + getCanonicalizedHeaderString(headers) + "\n" + getSignedHeadersString(headers) + "\n" + contentSha256;
   240  
   241          LOG.debug("AWS4 Canonical Request: '{}'", canonicalRequest);
   242          return canonicalRequest;
   243      }
   244  
   245      // source at https://github.com/aws/aws-sdk-java/blob/d1790c78af50488f38d758fd1f654d035b505150/src/main/java/com/amazonaws/auth/AWS4Signer.java#L257C22-L257C37
   246      protected String getStringToSign(String algorithm, String dateTime, String scope, String canonicalRequest) {
   247          String stringToSign = algorithm + "\n" + dateTime + "\n" + scope + "\n" + BinaryUtils.toHex(hash(canonicalRequest));
   248          LOG.debug("AWS4 String to Sign: '{}'", stringToSign);
   249          return stringToSign;
   250      }
   251      // source at https://github.com/aws/aws-sdk-java/blob/679abaebd371b09e887afaa5386dc182be4c6498/aws-java-sdk-core/src/main/java/com/amazonaws/util/SdkHttpUtils.java#L66
   252  
   253      /**
   254       * Encode a string for use in the path of a URL; uses URLEncoder.encode,
   255       * (which encodes a string for use in the query portion of a URL), then
   256       * applies some postfilters to fix things up per the RFC. Can optionally
   257       * handle strings which are meant to encode a path (ie include '/'es
   258       * which should NOT be escaped).
   259       *
   260       * @param value the value to encode
   261       * @param path  true if the value is intended to represent a path
   262       * @return the encoded value
   263       */
   264  
   265      public static String urlEncode(final String value, final boolean path) {
   266          if (value == null) {
   267              return "";
   268          }
   269          try {
   270              String encoded = URLEncoder.encode(value, DEFAULT_ENCODING);
   271  
   272              Matcher matcher = ENCODED_CHARACTERS_PATTERN.matcher(encoded);
   273              StringBuffer buffer = new StringBuffer(encoded.length());
   274  
   275              while (matcher.find()) {
   276                  String replacement = matcher.group(0);
   277  
   278                  if ("+".equals(replacement)) {
   279                      replacement = "%20";
   280                  } else if ("*".equals(replacement)) {
   281                      replacement = "%2A";
   282                  } else if ("%7E".equals(replacement)) {
   283                      replacement = "~";
   284                  } else if (path && "%2F".equals(replacement)) {
   285                      replacement = "/";
   286                  }
   287  
   288                  matcher.appendReplacement(buffer, replacement);
   289              }
   290  
   291              matcher.appendTail(buffer);
   292              return buffer.toString();
   293  
   294          } catch (UnsupportedEncodingException ex) {
   295              throw new RuntimeException(ex);
   296          }
   297      }
   298  
   299      /**
   300       * Returns true if the specified URI is using a non-standard port (i.e. any
   301       * port other than 80 for HTTP URIs or any port other than 443 for HTTPS
   302       * URIs).
   303       *
   304       * @param uri
   305       * @return True if the specified URI is using a non-standard port, otherwise
   306       * false.
   307       */
   308      public static boolean isUsingNonDefaultPort(URI uri) {
   309          String scheme = uri.getScheme().toLowerCase();
   310          int port = uri.getPort();
   311  
   312          if (port <= 0) return false;
   313          if (scheme.equals("http") && port == 80) return false;
   314          if (scheme.equals("https") && port == 443) return false;
   315  
   316          return true;
   317      }
   318  
   319      // cloned original addHostHeader in AWS4Signer
   320      protected String getHostHeader(URI endpoint) {
   321          StringBuilder hostHeaderBuilder = new StringBuilder(endpoint.getHost());
   322          if (isUsingNonDefaultPort(endpoint)) {
   323              hostHeaderBuilder.append(":").append(endpoint.getPort());
   324          }
   325          return hostHeaderBuilder.toString();
   326      }
   327  
   328      public GeneratePresignGetCallerIdentityResponse presignRequest(GeneratePresignGetCallerIdentityRequest input) {
   329  
   330          AWSCredentials sanitizedCredentials = sanitizeCredentials(input.getCredentials());
   331          String region = AwsHostNameUtils.parseRegionName(input.getStsEndpoint());
   332          // TODO(isan) use global sdk commented out here
   333          int timeOffset = 0;
   334          Date date = getSignatureDate(timeOffset);
   335          long dateMilli = date.getTime();
   336          String dateStamp = getDateStamp(dateMilli);
   337          String timeStamp = getTimeStamp(System.currentTimeMillis());
   338          String scope = dateStamp + "/" + region + "/" + SERVICE_NAME + "/" + TERMINATOR;
   339          String signingCredentials = sanitizedCredentials.getAWSAccessKeyId() + "/" + scope;
   340  
   341          ///////////// compute signature  /////////////
   342  
   343          Map<String, String> headersToSign = new HashMap<String, String>() {{
   344              put("Host", getHostHeader(input.getStsEndpoint()));
   345          }};
   346          // add additional headers to sign (i.e X-lakeFS-Server-ID)
   347          for (Map.Entry<String, String> entry : input.getAdditionalHeaders().entrySet()) {
   348              headersToSign.put(entry.getKey(), entry.getValue());
   349          }
   350  
   351          Map<String, String> queryParamsToSign = new HashMap<String, String>() {{
   352              put(STSGetCallerIdentityPresigner.AMZ_ACTION_PARAM_NAME, "GetCallerIdentity");
   353              put(STSGetCallerIdentityPresigner.AMZ_VERSION_PARAM_NAME, "2011-06-15");
   354              put(STSGetCallerIdentityPresigner.AMZ_SECURITY_TOKEN_PARAM_NAME, ((AWSSessionCredentials) sanitizedCredentials).getSessionToken());
   355              put(STSGetCallerIdentityPresigner.AMZ_ALGORITHM_PARAM_NAME, ALGORITHM);
   356              put(STSGetCallerIdentityPresigner.AMZ_DATE_PARAM_NAME, timeStamp);
   357              put(STSGetCallerIdentityPresigner.AMZ_SIGNED_HEADERS_PARAM_NAME, getSignedHeadersString(headersToSign));
   358              put(STSGetCallerIdentityPresigner.AMZ_EXPIRES_PARAM_NAME, String.valueOf(input.getExpirationInSeconds()));
   359              put(STSGetCallerIdentityPresigner.AMZ_CREDENTIAL_PARAM_NAME, signingCredentials);
   360          }};
   361  
   362          // contentSha256 is HashedPayload Hex(SHA256Hash(""))
   363          // see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-string-to-sign
   364          byte[] contentDigest = hash("");
   365          String contentSha256 = BinaryUtils.toHex(contentDigest);
   366          String canonicalRequest = getCanonicalRequest("POST", queryParamsToSign, headersToSign, contentSha256);
   367  
   368          String stringToSign = getStringToSign(ALGORITHM, timeStamp, scope, canonicalRequest);
   369  
   370          byte[] signature = computeSignature(sanitizedCredentials, dateStamp, region, SERVICE_NAME, TERMINATOR, stringToSign);
   371  
   372          GeneratePresignGetCallerIdentityResponse result = new GeneratePresignGetCallerIdentityResponse(input,region, queryParamsToSign, headersToSign, BinaryUtils.toHex(signature));
   373          return result;
   374      }
   375  
   376      public byte[] computeSignature(AWSCredentials sanitizedCredentials, String dateStamp, String region, String SERVICE_NAME, String TERMINATOR, String stringToSign) {
   377          // AWS4 uses a series of derived keys, formed by hashing different
   378          // pieces of data
   379          byte[] kSecret = ("AWS4" + sanitizedCredentials.getAWSSecretKey()).getBytes();
   380          byte[] kDate = sign(dateStamp, kSecret, SigningAlgorithm.HmacSHA256);
   381          byte[] kRegion = sign(region, kDate, SigningAlgorithm.HmacSHA256);
   382          byte[] kService = sign(SERVICE_NAME, kRegion, SigningAlgorithm.HmacSHA256);
   383          byte[] kSigning = sign(TERMINATOR, kService, SigningAlgorithm.HmacSHA256);
   384  
   385          byte[] signature = sign(stringToSign.getBytes(), kSigning, SigningAlgorithm.HmacSHA256);
   386          return signature;
   387      }
   388  }