blob: e6b2333e1c54951afb816bde281578877d8e6b3a [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hadoop.ozone.s3.signature;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.MultivaluedMap;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
import org.apache.hadoop.ozone.s3.signature.AWSSignatureProcessor.LowerCaseKeyStringMap;
import org.apache.hadoop.ozone.s3.util.S3Utils;
import org.apache.hadoop.util.StringUtils;
import com.google.common.annotations.VisibleForTesting;
import static java.time.temporal.ChronoUnit.SECONDS;
import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.S3_AUTHINFO_CREATION_ERROR;
import org.apache.kerby.util.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Stateless utility to create stringToSign, the base of the signature.
*/
public final class StringToSignProducer {
public static final String X_AMZ_CONTENT_SHA256 = "X-Amz-Content-SHA256";
public static final String X_AMAZ_DATE = "X-Amz-Date";
private static final Logger LOG =
LoggerFactory.getLogger(StringToSignProducer.class);
private static final Charset UTF_8 = StandardCharsets.UTF_8;
private static final String NEWLINE = "\n";
private static final String HOST = "host";
private static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
/**
* Seconds in a week, which is the max expiration time Sig-v4 accepts.
*/
private static final long PRESIGN_URL_MAX_EXPIRATION_SECONDS =
60 * 60 * 24 * 7;
public static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
.withZone(ZoneOffset.UTC);
private StringToSignProducer() {
}
public static String createSignatureBase(
SignatureInfo signatureInfo,
ContainerRequestContext context
) throws Exception {
return createSignatureBase(signatureInfo,
context.getUriInfo().getRequestUri().getScheme(),
context.getMethod(),
context.getUriInfo().getRequestUri().getPath(),
LowerCaseKeyStringMap.fromHeaderMap(context.getHeaders()),
fromMultiValueToSingleValueMap(
context.getUriInfo().getQueryParameters()));
}
@VisibleForTesting
public static String createSignatureBase(
SignatureInfo signatureInfo,
String scheme,
String method,
String uri,
LowerCaseKeyStringMap headers,
Map<String, String> queryParams
) throws Exception {
StringBuilder strToSign = new StringBuilder();
// According to AWS sigv4 documentation, authorization header should be
// in following format.
// Authorization: algorithm Credential=access key ID/credential scope,
// SignedHeaders=SignedHeaders, Signature=signature
// Construct String to sign in below format.
// StringToSign =
// Algorithm + \n +
// RequestDateTime + \n +
// CredentialScope + \n +
// HashedCanonicalRequest
String credentialScope = signatureInfo.getCredentialScope();
// If the absolute path is empty, use a forward slash (/)
uri = (uri.trim().length() > 0) ? uri : "/";
// Encode URI and preserve forward slashes
strToSign.append(signatureInfo.getAlgorithm() + NEWLINE);
strToSign.append(signatureInfo.getDateTime() + NEWLINE);
strToSign.append(credentialScope + NEWLINE);
String canonicalRequest = buildCanonicalRequest(
scheme,
method,
uri,
signatureInfo.getSignedHeaders(),
headers,
queryParams,
!signatureInfo.isSignPayload());
strToSign.append(hash(canonicalRequest));
if (LOG.isDebugEnabled()) {
LOG.debug("canonicalRequest:[{}]", canonicalRequest);
LOG.debug("StringToSign:[{}]", strToSign);
}
return strToSign.toString();
}
public static Map<String, String> fromMultiValueToSingleValueMap(
MultivaluedMap<String, String> queryParameters
) {
Map<String, String> result = new HashMap<>();
for (String key : queryParameters.keySet()) {
result.put(key, queryParameters.getFirst(key));
}
return result;
}
public static String hash(String payload) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(payload.getBytes(UTF_8));
return Hex.encode(md.digest()).toLowerCase();
}
@VisibleForTesting
public static String buildCanonicalRequest(
String schema,
String method,
String uri,
String signedHeaders,
Map<String, String> headers,
Map<String, String> queryParams,
boolean unsignedPayload
) throws OS3Exception {
Iterable<String> parts = split("/", uri);
List<String> encParts = new ArrayList<>();
for (String p : parts) {
encParts.add(urlEncode(p));
}
String canonicalUri = join("/", encParts);
String canonicalQueryStr = getQueryParamString(queryParams);
StringBuilder canonicalHeaders = new StringBuilder();
for (String header : StringUtils.getStringCollection(signedHeaders, ";")) {
canonicalHeaders.append(header.toLowerCase());
canonicalHeaders.append(":");
if (headers.containsKey(header)) {
String headerValue = headers.get(header);
canonicalHeaders.append(headerValue);
canonicalHeaders.append(NEWLINE);
// Set for testing purpose only to skip date and host validation.
validateSignedHeader(schema, header, headerValue);
} else {
throw new RuntimeException("Header " + header + " not present in " +
"request but requested to be signed.");
}
}
String payloadHash;
if (UNSIGNED_PAYLOAD.equals(
headers.get(X_AMZ_CONTENT_SHA256)) || unsignedPayload) {
payloadHash = UNSIGNED_PAYLOAD;
} else {
payloadHash = headers.get(X_AMZ_CONTENT_SHA256);
}
String canonicalRequest = method + NEWLINE
+ canonicalUri + NEWLINE
+ canonicalQueryStr + NEWLINE
+ canonicalHeaders + NEWLINE
+ signedHeaders + NEWLINE
+ payloadHash;
return canonicalRequest;
}
/**
* String join that also works with empty strings.
*
* @return joined string
*/
private static String join(String glue, List<String> parts) {
StringBuilder result = new StringBuilder();
boolean addSeparator = false;
for (String p : parts) {
if (addSeparator) {
result.append(glue);
}
result.append(p);
addSeparator = true;
}
return result.toString();
}
/**
* Returns matching strings.
*
* @param regex Regular expression to split by
* @param whole The string to split
* @return pieces
*/
private static Iterable<String> split(String regex, String whole) {
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(whole);
List<String> result = new ArrayList<>();
int pos = 0;
while (m.find()) {
result.add(whole.substring(pos, m.start()));
pos = m.end();
}
result.add(whole.substring(pos));
return result;
}
private static String urlEncode(String str) {
try {
return S3Utils.urlEncode(str)
.replaceAll("\\+", "%20")
.replaceAll("\\*", "%2A")
.replaceAll("%7E", "~");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
private static String getQueryParamString(
Map<String, String> queryMap
) {
List<String> params = new ArrayList<>(queryMap.keySet());
// Sort by name, then by value
Collections.sort(params, (o1, o2) -> o1.equals(o2) ?
queryMap.get(o1).compareTo(queryMap.get(o2)) :
o1.compareTo(o2));
StringBuilder result = new StringBuilder();
for (String p : params) {
if (!p.equals("X-Amz-Signature")) {
if (result.length() > 0) {
result.append("&");
}
result.append(urlEncode(p));
result.append('=');
result.append(urlEncode(queryMap.get(p)));
}
}
return result.toString();
}
@VisibleForTesting
static void validateSignedHeader(
String schema,
String header,
String headerValue
)
throws OS3Exception {
switch (header) {
case HOST:
try {
URI hostUri = new URI(schema + "://" + headerValue);
InetAddress.getByName(hostUri.getHost());
// TODO: Validate if current request is coming from same host.
} catch (UnknownHostException | URISyntaxException e) {
LOG.error("Host value mentioned in signed header is not valid. " +
"Host:{}", headerValue);
throw S3_AUTHINFO_CREATION_ERROR;
}
break;
case X_AMAZ_DATE:
LocalDate date = LocalDate.parse(headerValue, TIME_FORMATTER);
LocalDate now = LocalDate.now();
if (date.isBefore(now.minus(PRESIGN_URL_MAX_EXPIRATION_SECONDS, SECONDS))
|| date.isAfter(now.plus(PRESIGN_URL_MAX_EXPIRATION_SECONDS,
SECONDS))) {
LOG.error("AWS date not in valid range. Request timestamp:{} should " +
"not be older than {} seconds.", headerValue,
PRESIGN_URL_MAX_EXPIRATION_SECONDS);
throw S3_AUTHINFO_CREATION_ERROR;
}
break;
case X_AMZ_CONTENT_SHA256:
// TODO: Construct request payload and match HEX(SHA256(requestPayload))
break;
default:
break;
}
}
}