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 }