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:
+ *
+ *
+ * - {@value #PARAM_REGION} (required): the region, e.g. {@code us-east-1}.
+ *
- {@value #PARAM_SERVICE}: the service name, {@value #DEFAULT_SERVICE} by default.
+ *
- {@value #PARAM_SESSION_TOKEN}: the session token of temporary credentials, sent in the
+ * {@code X-Amz-Security-Token} header.
+ *
- {@value #PARAM_UNSIGNED_PAYLOAD}: {@code true} to send {@value #UNSIGNED_PAYLOAD} instead
+ * of hashing the entity (S3 only).
+ *
+ *
+ * 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