diff --git a/changes.md b/changes.md index 227390c6f4..8b0c9fca8b 100644 --- a/changes.md +++ b/changes.md @@ -259,6 +259,12 @@ Changes log the match must end on a URI path segment boundary, so a route attached to "/mcp" matches "/mcp", "/mcp/tools" and "/mcp?x=1" but not "/mcpx". Useful for mounting several routers or applications under path prefixes on one connector. + - Added client-side AWS Signature Version 4 authentication (`ChallengeScheme.HTTP_AWS_SIGV4`, + `HttpAwsSigV4Helper` in the Crypto extension), for Amazon S3, the other AWS services and + the S3-compatible object stores such as MinIO. The region, the service (`s3` by + default), the session token of temporary credentials and an unsigned payload option are + given as parameters of the `ChallengeResponse`. The `HTTP_AWS_S3` scheme still uses + Signature Version 2, which AWS no longer accepts for buckets created since June 2020. - Bugs fixed - The Netty connectors now flush each full output buffer of a request or response body, so a producer that pauses after filling one no longer holds back what it produced diff --git a/org.restlet.ext.crypto/src/main/java/module-info.java b/org.restlet.ext.crypto/src/main/java/module-info.java index d25b1c35ac..5d66f83217 100644 --- a/org.restlet.ext.crypto/src/main/java/module-info.java +++ b/org.restlet.ext.crypto/src/main/java/module-info.java @@ -17,6 +17,7 @@ provides org.restlet.engine.security.AuthenticatorHelper with org.restlet.ext.crypto.internal.HttpAwsS3Helper, org.restlet.ext.crypto.internal.HttpAwsQueryHelper, + org.restlet.ext.crypto.internal.HttpAwsSigV4Helper, org.restlet.ext.crypto.internal.HttpDigestHelper, org.restlet.ext.crypto.internal.HttpAzureSharedKeyHelper, org.restlet.ext.crypto.internal.HttpAzureSharedKeyLiteHelper; diff --git a/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java b/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java new file mode 100644 index 0000000000..52c028e928 --- /dev/null +++ b/org.restlet.ext.crypto/src/main/java/org/restlet/ext/crypto/internal/HttpAwsSigV4Helper.java @@ -0,0 +1,577 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.crypto.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import org.restlet.Request; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Header; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.engine.header.ChallengeWriter; +import org.restlet.engine.header.HeaderConstants; +import org.restlet.engine.security.AuthenticatorHelper; +import org.restlet.ext.crypto.DigestUtils; +import org.restlet.representation.Representation; +import org.restlet.util.Series; + +/** + * Implements the client side of the AWS Signature Version 4 HTTP authentication ({@link + * ChallengeScheme#HTTP_AWS_SIGV4}), used by Amazon S3, the other AWS services and the S3-compatible + * object stores. + * + *

The identifier of the {@link ChallengeResponse} is the access key ID and its secret is the + * secret access key. Its parameters give: + * + *

+ * + *

The helper adds the {@code X-Amz-Date} header, unless the request already has one, and for S3 + * the {@code X-Amz-Content-Sha256} header. A request entity is hashed when it can be read again + * ({@link Representation#isTransient()} is {@code false}). A transient entity is sent as {@value + * #UNSIGNED_PAYLOAD} to S3 and is refused for the other services, which require the hash. + * + *

Every request header present when the {@code Authorization} header is written is signed, + * except the hop-by-hop headers, {@code Content-Length} and {@code User-Agent}, which a transport + * or a proxy may change. A header replaced afterwards, for example through the {@link + * HeaderConstants#ATTRIBUTE_RAW_REQUEST_HEADERS} attribute, invalidates the signature. + * + *

The signed path and query are those the client connector sends: a relative reference is + * resolved against its base. S3 signs the path as is, each segment decoded then URI-encoded once. + * The other services sign it normalized (empty and dot segments removed) and URI-encoded once more + * as sent, so an escape in the path is encoded again. Query parameters are signed sorted and + * URI-encoded: a {@code +} in the query is a literal plus sign, so encode spaces as {@code %20}. + * + * @see Create + * a signed AWS API request + */ +public class HttpAwsSigV4Helper extends AuthenticatorHelper { + + /** The name of the parameter giving the region. */ + public static final String PARAM_REGION = "region"; + + /** The name of the parameter giving the service. */ + public static final String PARAM_SERVICE = "service"; + + /** The name of the parameter giving the session token of temporary credentials. */ + public static final String PARAM_SESSION_TOKEN = "sessionToken"; + + /** The name of the parameter asking not to hash the entity (S3 only). */ + public static final String PARAM_UNSIGNED_PAYLOAD = "unsignedPayload"; + + /** The default service. */ + public static final String DEFAULT_SERVICE = "s3"; + + /** The payload hash sent instead of the entity's hash (S3 only). */ + public static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + + /** The signing algorithm. */ + static final String ALGORITHM = "AWS4-HMAC-SHA256"; + + /** The hex SHA-256 hash of an empty payload. */ + static final String EMPTY_PAYLOAD_HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + /** The header giving the signing date and time. */ + static final String HEADER_AMZ_DATE = "X-Amz-Date"; + + /** The header giving the payload hash, required by S3. */ + static final String HEADER_AMZ_CONTENT_SHA256 = "X-Amz-Content-Sha256"; + + /** The header giving the session token of temporary credentials. */ + static final String HEADER_AMZ_SECURITY_TOKEN = "X-Amz-Security-Token"; + + /** The headers never signed, in lower case. */ + private static final Set UNSIGNED_HEADERS = + Set.of( + "authorization", + "connection", + "content-length", + "expect", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + "x-amzn-trace-id"); + + /** The format of the X-Amz-Date header. */ + private static final DateTimeFormatter AMZ_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + private static final HexFormat HEX = HexFormat.of(); + + private static final HexFormat HEX_UPPER = HexFormat.of().withUpperCase(); + + /** The clock giving the signing time. */ + private final Clock clock; + + /** Constructor. */ + public HttpAwsSigV4Helper() { + this(Clock.systemUTC()); + } + + /** + * Constructor. + * + * @param clock The clock giving the signing time. + */ + HttpAwsSigV4Helper(Clock clock) { + super(ChallengeScheme.HTTP_AWS_SIGV4, true, false); + this.clock = clock; + } + + @Override + public void formatResponse( + ChallengeWriter cw, + ChallengeResponse challenge, + Request request, + Series

httpHeaders) { + if (httpHeaders == null) { + throw new IllegalArgumentException("AWS SigV4 signing needs the request headers"); + } + + String accessKeyId = challenge.getIdentifier(); + char[] secret = challenge.getSecret(); + if (accessKeyId == null || accessKeyId.isEmpty() || secret == null || secret.length == 0) { + throw new IllegalArgumentException( + "AWS SigV4 signing needs an access key ID and a secret access key"); + } + + Series params = challenge.getParameters(); + String region = params.getFirstValue(PARAM_REGION); + if (region == null || region.isBlank()) { + throw new IllegalArgumentException( + "AWS SigV4 signing needs the '" + PARAM_REGION + "' parameter"); + } + String service = params.getFirstValue(PARAM_SERVICE); + if (service == null || service.isBlank()) { + service = DEFAULT_SERVICE; + } + boolean s3 = DEFAULT_SERVICE.equals(service); + + // Signing date and time + String amzDate = httpHeaders.getFirstValue(HEADER_AMZ_DATE, true); + if (amzDate == null || amzDate.isBlank()) { + amzDate = AMZ_DATE_FORMAT.format(clock.instant()); + httpHeaders.set(HEADER_AMZ_DATE, amzDate, true); + } else if (amzDate.length() < 8) { + throw new IllegalArgumentException("Invalid " + HEADER_AMZ_DATE + " header"); + } + + // Temporary credentials + String sessionToken = params.getFirstValue(PARAM_SESSION_TOKEN); + String tokenHeader = httpHeaders.getFirstValue(HEADER_AMZ_SECURITY_TOKEN, true); + if (sessionToken != null + && !sessionToken.isBlank() + && (tokenHeader == null || tokenHeader.isBlank())) { + httpHeaders.set(HEADER_AMZ_SECURITY_TOKEN, sessionToken, true); + } + + // Payload hash + String payloadHash = httpHeaders.getFirstValue(HEADER_AMZ_CONTENT_SHA256, true); + if (payloadHash == null || payloadHash.isBlank()) { + if (s3 && Boolean.parseBoolean(params.getFirstValue(PARAM_UNSIGNED_PAYLOAD))) { + payloadHash = UNSIGNED_PAYLOAD; + } else { + payloadHash = getPayloadHash(request.getEntity()); + if (payloadHash == null) { + if (!s3) { + throw new IllegalArgumentException( + "AWS SigV4 signing for the '" + + service + + "' service needs an entity that can be read again"); + } + payloadHash = UNSIGNED_PAYLOAD; + } + } + if (s3) { + httpHeaders.set(HEADER_AMZ_CONTENT_SHA256, payloadHash, true); + } + } + + // Host, normally added before the authorization header + if (httpHeaders.getFirstValue(HeaderConstants.HEADER_HOST, true) == null) { + String host = getHost(request); + if (host != null) { + httpHeaders.add(HeaderConstants.HEADER_HOST, host); + } + } + + // Sign the reference the client connector sends: a relative one is resolved against its + // base, as ReferenceUtils.update does, and an absolute one is kept as is. + Reference resourceRef = request.getResourceRef(); + if (!resourceRef.isAbsolute()) { + resourceRef = resourceRef.getTargetRef(); + } + + Map canonicalHeaders = getCanonicalHeaders(httpHeaders); + String signedHeaders = String.join(";", canonicalHeaders.keySet()); + String canonicalRequest = + getCanonicalRequest( + request.getMethod().getName(), + resourceRef, + !s3, + canonicalHeaders, + signedHeaders, + payloadHash); + String scope = amzDate.substring(0, 8) + "/" + region + "/" + service + "/aws4_request"; + String stringToSign = getStringToSign(amzDate, scope, canonicalRequest); + String signature = + getSignature(secret, amzDate.substring(0, 8), region, service, stringToSign); + + cw.append("Credential=") + .append(accessKeyId) + .append('/') + .append(scope) + .append(", SignedHeaders=") + .append(signedHeaders) + .append(", Signature=") + .append(signature); + } + + /** + * Returns the canonical headers to sign, by lower-case name, sorted. The values of a repeated + * header are joined with commas, and each value is trimmed with its inner whitespace runs + * collapsed to one space. + * + * @param httpHeaders The request headers. + * @return The canonical headers to sign. + */ + static Map getCanonicalHeaders(Series
httpHeaders) { + Map result = new TreeMap<>(); + for (Header header : httpHeaders) { + String name = header.getName().toLowerCase(Locale.ROOT); + if (!UNSIGNED_HEADERS.contains(name)) { + String value = + header.getValue() == null + ? "" + : header.getValue().trim().replaceAll("\\s+", " "); + result.merge(name, value, (a, b) -> a + "," + b); + } + } + return result; + } + + /** + * Returns the canonical query string: the parameters URI-encoded, sorted by name then value. + * + * @param rawQuery The raw (encoded) query, or null. + * @return The canonical query string. + */ + static String getCanonicalQuery(String rawQuery) { + if (rawQuery == null || rawQuery.isEmpty()) { + return ""; + } + List pairs = new ArrayList<>(); + for (String param : rawQuery.split("&")) { + if (!param.isEmpty()) { + int eq = param.indexOf('='); + String name = eq < 0 ? param : param.substring(0, eq); + String value = eq < 0 ? "" : param.substring(eq + 1); + pairs.add( + new String[] { + uriEncode(percentDecode(name)), uriEncode(percentDecode(value)) + }); + } + } + pairs.sort((a, b) -> a[0].equals(b[0]) ? a[1].compareTo(b[1]) : a[0].compareTo(b[0])); + StringBuilder sb = new StringBuilder(); + for (String[] pair : pairs) { + if (!sb.isEmpty()) { + sb.append('&'); + } + sb.append(pair[0]).append('=').append(pair[1]); + } + return sb.toString(); + } + + /** + * Returns the canonical request. + * + * @param method The method name. + * @param resourceRef The target reference. + * @param doubleEncodePath True to URI-encode the path segments twice (all services but S3). + * @param canonicalHeaders The canonical headers. + * @param signedHeaders The signed header names, separated by semicolons. + * @param payloadHash The payload hash. + * @return The canonical request. + */ + static String getCanonicalRequest( + String method, + Reference resourceRef, + boolean doubleEncodePath, + Map canonicalHeaders, + String signedHeaders, + String payloadHash) { + StringBuilder sb = new StringBuilder(); + sb.append(method).append('\n'); + sb.append(getCanonicalUri(resourceRef.getPath(), doubleEncodePath)).append('\n'); + sb.append(getCanonicalQuery(resourceRef.getQuery())).append('\n'); + canonicalHeaders.forEach( + (name, value) -> sb.append(name).append(':').append(value).append('\n')); + sb.append('\n'); + sb.append(signedHeaders).append('\n'); + sb.append(payloadHash); + return sb.toString(); + } + + /** + * Returns the canonical URI. + * + *

S3 decodes the path it receives and encodes it again: each segment is decoded, then + * URI-encoded once, and the path is otherwise signed as sent. + * + *

The other services encode the path they receive once more ("double encoding"): the path is + * normalized (see {@link #normalizePath(String)}) and each raw segment is URI-encoded as is, so + * an escape already in the path is encoded again ({@code %7E} is signed as {@code %257E}) and a + * character sent unescaped is encoded once ({@code ~} stays {@code ~}). + * + * @param rawPath The raw (encoded) path, or null. + * @param doubleEncode True for the services other than S3. + * @return The canonical URI. + */ + static String getCanonicalUri(String rawPath, boolean doubleEncode) { + if (rawPath == null || rawPath.isEmpty()) { + return "/"; + } + String path = doubleEncode ? normalizePath(rawPath) : rawPath; + String[] segments = path.split("/", -1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + sb.append('/'); + } + sb.append(uriEncode(doubleEncode ? segments[i] : percentDecode(segments[i]))); + } + return sb.charAt(0) == '/' ? sb.toString() : "/" + sb; + } + + /** + * Normalizes a path as AWS SigV4 requires for the services other than S3: empty and {@code .} + * segments are removed and a {@code ..} segment removes the previous one. The path keeps a + * trailing slash when it ends with a slash or with a {@code .} or {@code ..} segment, as in RFC + * 3986. For example {@code /a//b/./c/../d} becomes {@code /a/b/d}, {@code //example//} becomes + * {@code /example/} and {@code /a/b/..} becomes {@code /a/}. + * + * @param rawPath The raw path. + * @return The normalized path, starting with a slash. + */ + static String normalizePath(String rawPath) { + Deque segments = new ArrayDeque<>(); + String[] rawSegments = rawPath.split("/", -1); + for (String segment : rawSegments) { + if (segment.equals("..")) { + segments.pollLast(); + } else if (!segment.isEmpty() && !segment.equals(".")) { + segments.addLast(segment); + } + } + String last = rawSegments[rawSegments.length - 1]; + boolean trailingSlash = last.isEmpty() || last.equals(".") || last.equals(".."); + String result = "/" + String.join("/", segments); + return trailingSlash && !result.endsWith("/") ? result + "/" : result; + } + + /** + * Returns the hex SHA-256 hash of an entity, or null when it can't be read again. + * + * @param entity The entity, or null. + * @return The hex SHA-256 hash, or null. + */ + static String getPayloadHash(Representation entity) { + if (entity == null || !entity.isAvailable() || entity.getSize() == 0) { + return EMPTY_PAYLOAD_HASH; + } + if (entity.isTransient()) { + return null; + } + MessageDigest digest = sha256(); + try (InputStream in = entity.getStream()) { + if (in != null) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + } catch (IOException e) { + throw new IllegalArgumentException("Unable to hash the request entity", e); + } + return HEX.formatHex(digest.digest()); + } + + /** + * Returns the hex signature of a string to sign. + * + * @param secret The secret access key. + * @param date The date, as yyyyMMdd. + * @param region The region. + * @param service The service. + * @param stringToSign The string to sign. + * @return The hex signature. + */ + static String getSignature( + char[] secret, String date, String region, String service, String stringToSign) { + char[] prefixed = new char[secret.length + 4]; + "AWS4".getChars(0, 4, prefixed, 0); + System.arraycopy(secret, 0, prefixed, 4, secret.length); + ByteBuffer encoded = StandardCharsets.UTF_8.encode(CharBuffer.wrap(prefixed)); + byte[] key = new byte[encoded.remaining()]; + encoded.get(key); + Arrays.fill(prefixed, '\0'); + if (encoded.hasArray()) { + Arrays.fill(encoded.array(), (byte) 0); + } + + try { + byte[] dateKey = DigestUtils.toHMacSha256(date, key); + byte[] regionKey = DigestUtils.toHMacSha256(region, dateKey); + byte[] serviceKey = DigestUtils.toHMacSha256(service, regionKey); + byte[] signingKey = DigestUtils.toHMacSha256("aws4_request", serviceKey); + return HEX.formatHex(DigestUtils.toHMacSha256(stringToSign, signingKey)); + } finally { + Arrays.fill(key, (byte) 0); + } + } + + /** + * Returns the string to sign. + * + * @param amzDate The signing date and time. + * @param scope The credential scope. + * @param canonicalRequest The canonical request. + * @return The string to sign. + */ + static String getStringToSign(String amzDate, String scope, String canonicalRequest) { + return ALGORITHM + + "\n" + + amzDate + + "\n" + + scope + + "\n" + + HEX.formatHex(sha256().digest(canonicalRequest.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Returns the Host header value of a request: its host, with its port when not the default. + * + * @param request The request. + * @return The Host header value, or null. + */ + private static String getHost(Request request) { + Reference ref = request.getResourceRef(); + String host = ref.getHostDomain(); + if (host == null) { + return null; + } + int port = ref.getHostPort(); + Protocol protocol = ref.getSchemeProtocol(); + if (port != -1 && (protocol == null || port != protocol.getDefaultPort())) { + host = host + ':' + port; + } + return host; + } + + /** + * Decodes the %XX sequences of a string as UTF-8. Other characters, including {@code +}, are + * kept. + * + * @param value The value to decode. + * @return The decoded value. + */ + static String percentDecode(String value) { + if (value.indexOf('%') < 0) { + return value; + } + ByteArrayOutputStream out = new ByteArrayOutputStream(value.length()); + int i = 0; + while (i < value.length()) { + char c = value.charAt(i); + if (c == '%' + && i + 2 < value.length() + && HexFormat.isHexDigit(value.charAt(i + 1)) + && HexFormat.isHexDigit(value.charAt(i + 2))) { + out.write(HexFormat.fromHexDigits(value, i + 1, i + 3)); + i += 3; + } else { + int end = Character.isHighSurrogate(c) && i + 1 < value.length() ? i + 2 : i + 1; + out.writeBytes(value.substring(i, end).getBytes(StandardCharsets.UTF_8)); + i = end; + } + } + return out.toString(StandardCharsets.UTF_8); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + + /** + * URI-encodes a value as AWS SigV4 requires: the unreserved characters (letters, digits, {@code + * -._~}) are kept, every other UTF-8 byte is written as %XX in upper case. + * + * @param value The value to encode. + * @return The encoded value. + */ + static String uriEncode(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (byte b : value.getBytes(StandardCharsets.UTF_8)) { + int c = b & 0xff; + if ((c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '.' + || c == '_' + || c == '~') { + sb.append((char) c); + } else { + sb.append('%').append(HEX_UPPER.toHexDigits((byte) c)); + } + } + return sb.toString(); + } +} diff --git a/org.restlet.ext.crypto/src/main/resources/META-INF/services/org.restlet.engine.security.AuthenticatorHelper b/org.restlet.ext.crypto/src/main/resources/META-INF/services/org.restlet.engine.security.AuthenticatorHelper index 9ae85f1d8b..c939920f48 100644 --- a/org.restlet.ext.crypto/src/main/resources/META-INF/services/org.restlet.engine.security.AuthenticatorHelper +++ b/org.restlet.ext.crypto/src/main/resources/META-INF/services/org.restlet.engine.security.AuthenticatorHelper @@ -1,5 +1,6 @@ org.restlet.ext.crypto.internal.HttpAwsS3Helper org.restlet.ext.crypto.internal.HttpAwsQueryHelper +org.restlet.ext.crypto.internal.HttpAwsSigV4Helper org.restlet.ext.crypto.internal.HttpDigestHelper org.restlet.ext.crypto.internal.HttpAzureSharedKeyHelper org.restlet.ext.crypto.internal.HttpAzureSharedKeyLiteHelper diff --git a/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java b/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java new file mode 100644 index 0000000000..773abc8859 --- /dev/null +++ b/org.restlet.ext.crypto/src/test/java/org/restlet/ext/crypto/internal/HttpAwsSigV4HelperTestCase.java @@ -0,0 +1,617 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.crypto.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Header; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import org.restlet.engine.header.ChallengeWriter; +import org.restlet.engine.header.HeaderConstants; +import org.restlet.representation.InputRepresentation; +import org.restlet.representation.StringRepresentation; +import org.restlet.util.Series; + +/** + * Tests {@link HttpAwsSigV4Helper}. The expected signatures come from the AWS documentation: the + * Amazon S3 examples of "Signature Calculations for the Authorization Header: Transferring Payload + * in a Single Chunk" and the generic AWS Signature Version 4 test suite. + */ +class HttpAwsSigV4HelperTestCase { + + private static final String S3_KEY_ID = "AKIAIOSFODNN7EXAMPLE"; + + private static final String S3_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + + private static final String S3_HOST = "examplebucket.s3.amazonaws.com"; + + private static final Clock S3_CLOCK = + Clock.fixed(Instant.parse("2013-05-24T00:00:00Z"), ZoneOffset.UTC); + + private static final String SUITE_KEY_ID = "AKIDEXAMPLE"; + + private static final String SUITE_SECRET = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + + private static final String SUITE_HOST = "example.amazonaws.com"; + + private static final Clock SUITE_CLOCK = + Clock.fixed(Instant.parse("2015-08-30T12:36:00Z"), ZoneOffset.UTC); + + private static ChallengeResponse challenge( + String keyId, String secret, String region, String service) { + ChallengeResponse challenge = + new ChallengeResponse(ChallengeScheme.HTTP_AWS_SIGV4, keyId, secret); + if (region != null) { + challenge.getParameters().add(HttpAwsSigV4Helper.PARAM_REGION, region); + } + if (service != null) { + challenge.getParameters().add(HttpAwsSigV4Helper.PARAM_SERVICE, service); + } + return challenge; + } + + private static Series

headers(String host) { + Series
headers = new Series<>(Header.class); + headers.add(HeaderConstants.HEADER_HOST, host); + return headers; + } + + private static String sign( + Clock clock, ChallengeResponse challenge, Request request, Series
headers) { + ChallengeWriter cw = new ChallengeWriter(); + new HttpAwsSigV4Helper(clock).formatResponse(cw, challenge, request, headers); + return cw.toString(); + } + + private static String sha256Hex(byte[] bytes) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + + @Test + void s3GetObject() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/test.txt"); + Series
headers = headers(S3_HOST); + headers.add(HeaderConstants.HEADER_RANGE, "bytes=0-9"); + + String result = + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), + request, + headers); + + assertEquals( + "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request," + + " SignedHeaders=host;range;x-amz-content-sha256;x-amz-date," + + " Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41", + result); + assertEquals("20130524T000000Z", headers.getFirstValue("x-amz-date", true)); + assertEquals( + HttpAwsSigV4Helper.EMPTY_PAYLOAD_HASH, + headers.getFirstValue("x-amz-content-sha256", true)); + } + + @Test + void s3GetObjectCanonicalRequestAndStringToSign() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/test.txt"); + Series
headers = headers(S3_HOST); + headers.add(HeaderConstants.HEADER_RANGE, "bytes=0-9"); + headers.add("x-amz-content-sha256", HttpAwsSigV4Helper.EMPTY_PAYLOAD_HASH); + headers.add("x-amz-date", "20130524T000000Z"); + Map canonicalHeaders = HttpAwsSigV4Helper.getCanonicalHeaders(headers); + + String canonicalRequest = + HttpAwsSigV4Helper.getCanonicalRequest( + "GET", + request.getResourceRef(), + false, + canonicalHeaders, + String.join(";", canonicalHeaders.keySet()), + HttpAwsSigV4Helper.EMPTY_PAYLOAD_HASH); + + assertEquals( + """ + GET + /test.txt + + host:examplebucket.s3.amazonaws.com + range:bytes=0-9 + x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + x-amz-date:20130524T000000Z + + host;range;x-amz-content-sha256;x-amz-date + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855""", + canonicalRequest); + assertEquals( + """ + AWS4-HMAC-SHA256 + 20130524T000000Z + 20130524/us-east-1/s3/aws4_request + 7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972""", + HttpAwsSigV4Helper.getStringToSign( + "20130524T000000Z", + "20130524/us-east-1/s3/aws4_request", + canonicalRequest)); + } + + @Test + void s3PutObject() { + Request request = + new Request( + Method.PUT, + "http://" + S3_HOST + "/test$file.text", + new StringRepresentation("Welcome to Amazon S3.", MediaType.TEXT_PLAIN)); + Series
headers = headers(S3_HOST); + headers.add(HeaderConstants.HEADER_DATE, "Fri, 24 May 2013 00:00:00 GMT"); + headers.add("x-amz-storage-class", "REDUCED_REDUNDANCY"); + + String result = + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", "s3"), + request, + headers); + + assertEquals( + "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request," + + " SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class," + + " Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd", + result); + assertEquals( + "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072", + headers.getFirstValue("x-amz-content-sha256", true)); + } + + @Test + void s3GetBucketLifecycle() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/?lifecycle"); + + String result = + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), + request, + headers(S3_HOST)); + + assertEquals( + "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request," + + " SignedHeaders=host;x-amz-content-sha256;x-amz-date," + + " Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543", + result); + } + + @Test + void s3ListObjects() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/?max-keys=2&prefix=J"); + + String result = + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), + request, + headers(S3_HOST)); + + assertEquals( + "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request," + + " SignedHeaders=host;x-amz-content-sha256;x-amz-date," + + " Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", + result); + } + + @Test + void suiteGetVanilla() { + Request request = new Request(Method.GET, "http://" + SUITE_HOST + "/"); + Series
headers = headers(SUITE_HOST); + + String result = + sign( + SUITE_CLOCK, + challenge(SUITE_KEY_ID, SUITE_SECRET, "us-east-1", "service"), + request, + headers); + + assertEquals( + "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request," + + " SignedHeaders=host;x-amz-date," + + " Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31", + result); + // Only S3 needs the payload hash header + assertEquals(null, headers.getFirstValue("x-amz-content-sha256", true)); + } + + @Test + void suiteGetVanillaQueryOrderKeyCase() { + Request request = + new Request(Method.GET, "http://" + SUITE_HOST + "/?Param2=value2&Param1=value1"); + + String result = + sign( + SUITE_CLOCK, + challenge(SUITE_KEY_ID, SUITE_SECRET, "us-east-1", "service"), + request, + headers(SUITE_HOST)); + + assertEquals( + "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request," + + " SignedHeaders=host;x-amz-date," + + " Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500", + result); + } + + @Test + void keepsAnExistingDateAndAddsTheSessionToken() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/test.txt"); + Series
headers = headers(S3_HOST); + headers.add("X-Amz-Date", "20130524T000000Z"); + ChallengeResponse challenge = challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null); + challenge.getParameters().add(HttpAwsSigV4Helper.PARAM_SESSION_TOKEN, "token+/="); + + String result = sign(Clock.systemUTC(), challenge, request, headers); + + assertEquals(1, headers.subList("x-amz-date", true).size()); + assertEquals("token+/=", headers.getFirstValue("x-amz-security-token", true)); + assertTrue( + result.contains( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token,"), + result); + assertTrue(result.startsWith("Credential=" + S3_KEY_ID + "/20130524/"), result); + } + + @Test + void sendsAnUnsignedPayloadToS3WhenAskedOrForATransientEntity() { + ChallengeResponse challenge = challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null); + challenge.getParameters().add(HttpAwsSigV4Helper.PARAM_UNSIGNED_PAYLOAD, "true"); + Request request = + new Request( + Method.PUT, + "http://" + S3_HOST + "/a", + new StringRepresentation("data", MediaType.TEXT_PLAIN)); + Series
headers = headers(S3_HOST); + sign(S3_CLOCK, challenge, request, headers); + assertEquals( + HttpAwsSigV4Helper.UNSIGNED_PAYLOAD, + headers.getFirstValue("x-amz-content-sha256", true)); + + request.setEntity( + new InputRepresentation( + new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)), + MediaType.TEXT_PLAIN)); + headers = headers(S3_HOST); + sign(S3_CLOCK, challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), request, headers); + assertEquals( + HttpAwsSigV4Helper.UNSIGNED_PAYLOAD, + headers.getFirstValue("x-amz-content-sha256", true)); + } + + @Test + void refusesATransientEntityForOtherServices() { + Request request = + new Request( + Method.POST, + "http://" + SUITE_HOST + "/", + new InputRepresentation( + new ByteArrayInputStream("data".getBytes(StandardCharsets.UTF_8)), + MediaType.TEXT_PLAIN)); + ChallengeResponse challenge = challenge(SUITE_KEY_ID, SUITE_SECRET, "us-east-1", "service"); + + assertThrows( + IllegalArgumentException.class, + () -> sign(SUITE_CLOCK, challenge, request, headers(SUITE_HOST))); + } + + @Test + void refusesMissingCredentialsOrRegion() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/"); + + assertThrows( + IllegalArgumentException.class, + () -> + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, null, null), + request, + headers(S3_HOST))); + assertThrows( + IllegalArgumentException.class, + () -> + sign( + S3_CLOCK, + challenge(S3_KEY_ID, "", "us-east-1", null), + request, + headers(S3_HOST))); + assertThrows( + IllegalArgumentException.class, + () -> + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), + request, + null)); + } + + @Test + void addsTheHostHeaderWhenMissing() { + Request request = new Request(Method.GET, "http://localhost:9000/bucket/key"); + Series
headers = new Series<>(Header.class); + + sign(S3_CLOCK, challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), request, headers); + + assertEquals("localhost:9000", headers.getFirstValue("host", true)); + } + + @Test + void canonicalUri() { + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri(null, false)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("", true)); + assertEquals( + "/documents%20and%20settings/", + HttpAwsSigV4Helper.getCanonicalUri("/documents and settings/", false)); + assertEquals( + "/documents%20and%20settings/", + HttpAwsSigV4Helper.getCanonicalUri("/documents%20and%20settings/", false)); + assertEquals( + "/documents%2520and%2520settings/", + HttpAwsSigV4Helper.getCanonicalUri("/documents%20and%20settings/", true)); + assertEquals("/caf%C3%A9", HttpAwsSigV4Helper.getCanonicalUri("/café", false)); + assertEquals("/a%2Bb%25zz", HttpAwsSigV4Helper.getCanonicalUri("/a+b%zz", false)); + } + + /** + * The services other than S3 sign the path normalized, as in the "normalize-path" cases of the + * AWS SigV4 test suite. S3 signs it as sent. + */ + @Test + void canonicalUriNormalizesThePathOfOtherServicesOnly() { + assertEquals("/a/b/d", HttpAwsSigV4Helper.getCanonicalUri("/a//b/./c/../d", true)); + assertEquals("/example/", HttpAwsSigV4Helper.getCanonicalUri("//example//", true)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("//", true)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("/example/..", true)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("/example1/example2/../..", true)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("/./", true)); + assertEquals("/example", HttpAwsSigV4Helper.getCanonicalUri("/./example", true)); + assertEquals("/", HttpAwsSigV4Helper.getCanonicalUri("/..", true)); + assertEquals("/a%2520b/c", HttpAwsSigV4Helper.getCanonicalUri("/a%20b/./x/../c", true)); + assertEquals("/a//b/./c/../d", HttpAwsSigV4Helper.getCanonicalUri("/a//b/./c/../d", false)); + + // A final dot segment keeps the trailing slash, as in RFC 3986 + assertEquals("/a/", HttpAwsSigV4Helper.getCanonicalUri("/a/b/..", true)); + assertEquals("/a/", HttpAwsSigV4Helper.getCanonicalUri("/a/.", true)); + assertEquals("/a/", HttpAwsSigV4Helper.getCanonicalUri("/a/./", true)); + assertEquals("/a/", HttpAwsSigV4Helper.getCanonicalUri("/a/b/../", true)); + } + + /** + * The services other than S3 encode the path as sent once more, so an escape is encoded again + * rather than decoded. S3 decodes the path then encodes it once. + */ + @Test + void canonicalUriEncodesTheSentPathAgainForOtherServices() { + assertEquals("/items/%257E", HttpAwsSigV4Helper.getCanonicalUri("/items/%7E", true)); + assertEquals("/items/%257e", HttpAwsSigV4Helper.getCanonicalUri("/items/%7e", true)); + assertEquals("/items/~", HttpAwsSigV4Helper.getCanonicalUri("/items/~", true)); + // The "get-utf8" and "get-space" cases of the AWS SigV4 test suite + assertEquals("/%E1%88%B4", HttpAwsSigV4Helper.getCanonicalUri("/ሴ", true)); + assertEquals( + "/example%20space/", HttpAwsSigV4Helper.getCanonicalUri("/example space/", true)); + + assertEquals("/items/~", HttpAwsSigV4Helper.getCanonicalUri("/items/%7E", false)); + assertEquals("/items/~", HttpAwsSigV4Helper.getCanonicalUri("/items/%7e", false)); + } + + /** A relative reference is signed as the client connector resolves it against its base. */ + @Test + void signsARelativeReferenceAsTheConnectorResolvesIt() { + Request relative = + new Request( + Method.GET, + new Reference(new Reference("http://" + S3_HOST + "/bucket/"), "key?x=1")); + Request absolute = new Request(Method.GET, "http://" + S3_HOST + "/bucket/key?x=1"); + ChallengeResponse challenge = challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null); + + assertEquals( + sign(S3_CLOCK, challenge, absolute, headers(S3_HOST)), + sign(S3_CLOCK, challenge, relative, headers(S3_HOST))); + } + + /** A blank signing header is replaced, not followed by a second value. */ + @Test + void replacesBlankSigningHeaders() { + Request request = new Request(Method.GET, "http://" + S3_HOST + "/test.txt"); + Series
headers = headers(S3_HOST); + headers.add(HeaderConstants.HEADER_RANGE, "bytes=0-9"); + headers.add("X-Amz-Date", ""); + headers.add("X-Amz-Content-Sha256", " "); + + String result = + sign( + S3_CLOCK, + challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null), + request, + headers); + + assertEquals( + "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request," + + " SignedHeaders=host;range;x-amz-content-sha256;x-amz-date," + + " Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41", + result); + assertEquals(1, headers.subList("x-amz-date", true).size()); + assertEquals(1, headers.subList("x-amz-content-sha256", true).size()); + + Series
tokenHeaders = headers(S3_HOST); + tokenHeaders.add("X-Amz-Security-Token", ""); + ChallengeResponse challenge = challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null); + challenge.getParameters().add(HttpAwsSigV4Helper.PARAM_SESSION_TOKEN, "token"); + sign(S3_CLOCK, challenge, request, tokenHeaders); + assertEquals(1, tokenHeaders.subList("x-amz-security-token", true).size()); + assertEquals("token", tokenHeaders.getFirstValue("x-amz-security-token", true)); + } + + @Test + void canonicalQuery() { + assertEquals("", HttpAwsSigV4Helper.getCanonicalQuery(null)); + assertEquals("a=0&a=1&b=2&c=", HttpAwsSigV4Helper.getCanonicalQuery("b=2&a=1&&a=0&c")); + assertEquals( + "prefix=a%20b%2Bc&x=%2F", + HttpAwsSigV4Helper.getCanonicalQuery("x=/&prefix=a%20b+c")); + } + + @Test + void canonicalHeaders() { + Series
headers = new Series<>(Header.class); + headers.add("Host", "h"); + headers.add("User-Agent", "Restlet"); + headers.add("Content-Length", "4"); + headers.add("Connection", "keep-alive"); + headers.add("X-Amz-Meta-A", " one two "); + headers.add("x-amz-meta-a", "three"); + + Map result = HttpAwsSigV4Helper.getCanonicalHeaders(headers); + + assertEquals(Set.of("host", "x-amz-meta-a"), result.keySet()); + assertEquals("one two,three", result.get("x-amz-meta-a")); + } + + @Test + void schemeIsRegistered() { + assertSame(ChallengeScheme.HTTP_AWS_SIGV4, ChallengeScheme.valueOf("http_aws_sigv4")); + assertEquals("AWS4-HMAC-SHA256", ChallengeScheme.HTTP_AWS_SIGV4.getTechnicalName()); + HttpAwsSigV4Helper helper = new HttpAwsSigV4Helper(); + assertTrue(helper.isClientSide()); + assertFalse(helper.isServerSide()); + } + + /** + * Sends a signed request through the client connector and checks, on the server, that the + * signature matches the headers, path, query and body actually received. + */ + @Test + void signsWhatTheClientConnectorSends() throws Exception { + AtomicReference received = new AtomicReference<>(); + AtomicReference receivedBody = new AtomicReference<>(); + Server server = + new Server( + new Context(), + Protocol.HTTP, + 0, + new Restlet() { + @Override + public void handle(Request request, Response response) { + try { + receivedBody.set(request.getEntity().getText()); + } catch (Exception e) { + response.setStatus(Status.SERVER_ERROR_INTERNAL, e); + return; + } + received.set(request); + response.setStatus(Status.SUCCESS_NO_CONTENT); + } + }); + server.start(); + Client client = new Client(new Context(), Protocol.HTTP); + client.start(); + try { + String body = "{\"note\":\"Welcome to \\\"S3\\\"\"}"; + Request request = + new Request( + Method.PUT, + "http://localhost:" + + server.getActualPort() + + "/bucket/my%20file$1.json?x-id=PutObject&a=b%2Fc", + new StringRepresentation(body, MediaType.APPLICATION_JSON)); + request.getHeaders().add("x-amz-meta-owner", "physics"); + request.setChallengeResponse(challenge(S3_KEY_ID, S3_SECRET, "us-east-1", null)); + + Response response = client.handle(request); + assertEquals(Status.SUCCESS_NO_CONTENT, response.getStatus()); + + Request seen = received.get(); + assertNotNull(seen); + Series
seenHeaders = seen.getHeaders(); + String authorization = seenHeaders.getFirstValue("Authorization", true); + assertTrue(authorization.startsWith("AWS4-HMAC-SHA256 Credential="), authorization); + + String signedHeaders = authorization.replaceAll(".*SignedHeaders=([^,]+),.*", "$1"); + String signature = authorization.replaceAll(".*Signature=([0-9a-f]+)$", "$1"); + Set signed = Set.of(signedHeaders.split(";")); + assertTrue( + signed.containsAll( + Set.of( + "host", + "content-type", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-meta-owner")), + signedHeaders); + + String payloadHash = seenHeaders.getFirstValue("x-amz-content-sha256", true); + assertEquals( + sha256Hex(receivedBody.get().getBytes(StandardCharsets.UTF_8)), payloadHash); + + Series
signedSeen = new Series<>(Header.class); + for (Header header : seenHeaders) { + if (signed.contains(header.getName().toLowerCase())) { + signedSeen.add(header); + } + } + Map canonicalHeaders = + HttpAwsSigV4Helper.getCanonicalHeaders(signedSeen); + assertEquals(signedHeaders, String.join(";", canonicalHeaders.keySet())); + + String amzDate = seenHeaders.getFirstValue("x-amz-date", true); + String scope = amzDate.substring(0, 8) + "/us-east-1/s3/aws4_request"; + String canonicalRequest = + HttpAwsSigV4Helper.getCanonicalRequest( + seen.getMethod().getName(), + seen.getResourceRef(), + false, + canonicalHeaders, + signedHeaders, + payloadHash); + assertEquals( + signature, + HttpAwsSigV4Helper.getSignature( + S3_SECRET.toCharArray(), + amzDate.substring(0, 8), + "us-east-1", + "s3", + HttpAwsSigV4Helper.getStringToSign(amzDate, scope, canonicalRequest))); + } finally { + client.stop(); + server.stop(); + } + } +} diff --git a/org.restlet/src/main/java/org/restlet/data/ChallengeScheme.java b/org.restlet/src/main/java/org/restlet/data/ChallengeScheme.java index 68e1a91377..97f83d2984 100644 --- a/org.restlet/src/main/java/org/restlet/data/ChallengeScheme.java +++ b/org.restlet/src/main/java/org/restlet/data/ChallengeScheme.java @@ -33,10 +33,25 @@ public final class ChallengeScheme { new ChallengeScheme( "HTTP_AWS_QUERY", "AWS_QUERY", "Amazon Query String authentication"); - /** Amazon S3 HTTP scheme. */ + /** + * Amazon S3 HTTP scheme, using AWS Signature Version 2. AWS no longer accepts it for buckets + * created since June 2020 nor in the regions opened since 2014: use {@link #HTTP_AWS_SIGV4}. + */ public static final ChallengeScheme HTTP_AWS_S3 = new ChallengeScheme("HTTP_AWS_S3", "AWS", "Amazon S3 HTTP authentication"); + /** + * AWS Signature Version 4 HTTP scheme, used by Amazon S3, the other AWS services and the + * S3-compatible object stores. Each request is signed with HMAC-SHA256 from the secret access + * key, the date, the region and the service. + * + * @see AWS + * Signature Version 4 for API requests + */ + public static final ChallengeScheme HTTP_AWS_SIGV4 = + new ChallengeScheme( + "HTTP_AWS_SIGV4", "AWS4-HMAC-SHA256", "AWS Signature Version 4 authentication"); + /** * Microsoft Azure Shared Key scheme. * @@ -99,6 +114,7 @@ public final class ChallengeScheme { Map.entry(HTTP_AWS_IAM.getName().toLowerCase(), HTTP_AWS_S3), Map.entry(HTTP_AWS_QUERY.getName().toLowerCase(), HTTP_AWS_S3), Map.entry(HTTP_AWS_S3.getName().toLowerCase(), HTTP_AWS_S3), + Map.entry(HTTP_AWS_SIGV4.getName().toLowerCase(), HTTP_AWS_SIGV4), Map.entry(HTTP_AZURE_SHAREDKEY.getName().toLowerCase(), HTTP_AZURE_SHAREDKEY), Map.entry( HTTP_AZURE_SHAREDKEY_LITE.getName().toLowerCase(), diff --git a/org.restlet/src/test/java/org/restlet/data/ChallengeSchemeTestCase.java b/org.restlet/src/test/java/org/restlet/data/ChallengeSchemeTestCase.java index 95d7535336..baeb326955 100644 --- a/org.restlet/src/test/java/org/restlet/data/ChallengeSchemeTestCase.java +++ b/org.restlet/src/test/java/org/restlet/data/ChallengeSchemeTestCase.java @@ -60,7 +60,7 @@ void valueOf_everyConstant_isFoundByItsName() throws IllegalAccessException { assertSame(constant, ChallengeScheme.valueOf(constant.getName()), field.getName()); checked++; } - assertEquals(12, checked); + assertEquals(13, checked); } @Test