Creating AWS request signatures

Unlike Google Cloud and Azure, AWS doesn’t use OAuth 2 and access tokens for letting clients authenticate to their API. Instead, AWS expects clients to calculate a signature for each request, and pass the signature in the Authorizationheader.

To calculate a signature, clients first create a canonicalized version of the request, including its parameters, headers, and body. Then, they take that request, add some extra bits like the time, and create a message authentication code (MAC) for it by using the HMAC-SHA256 function in combination with the user’s secret access key. The details are a bit more complicated and described at length in the AWS docs, but that’s the basic idea.

Compared to using OAuth 2 access tokens, the AWS method has some advantages:

  • The request signature not only proves the authenticity of the request, but also its integrity. If an attacker intercepts and modifies a request, then the signature doesn’t match the request anymore and the API will reject the request.

    In contrast, access tokens prove the authenticity of requests, but not their integrity. If an attacker intercepts and tampers with a request, the access token remains valid.

  • Because the signature includes the time, requests can’t be replayed… or at least, it becomes a bit more difficult to replay them: To accommodate for clock skew, timestamps are allowed to be off by a few minutes, and within that time window, replaying requests is possible. Nonetheless, the time window is shorter than access tokens are typically valid.

But there are also a few downsides:

  • Calculating a signature isn’t trivial. We not only need a crypto library that supports HMAC-SHA256, there’s also a fair bit of logic involved to canonicalize a request and generate the signature. In practice, that typically means we have to use one of the AWS libraries to make API requests.

    Adding a Authorization: Bearer ya... header to an HTTP request is trivial in comparison and that allows us to curl or Invoke-RestMethod to make ad-hoc requests to Azure and Google Cloud API.

  • There’s no easy way to let a third party perform an action on our behalf: When using OAuth access tokens, we can pass our access token to the third party, and the third party can use the token to perform API calls on our behalf. With signed requests, that’s not possible unless we pass our secret access key to the third party, which would be a terrible idea.

  • Only the client and AWS can validate a request as only those two parties know the secret access key. As a result, it’s also difficult to use AWS credentials to prove our identity to a third party: we can send a signed request to the third party, but the third party can’t validate the signature.

Using AWS credentials to prove our identity to a third party is critical if we want to perform any kind of credential exchange – for example, to get access to Vault or Google Cloud APIs.

Both Vault and Google Cloud let us perform a credential exchange, but the way they do it is a bit of a nasty trick: They ask us to let them be the man-in-the-middle for a GetCallerIdentity request.

For workload identity federation, the process is:

  1. The client creates a JSON document that contains the information that it would normally include in a request to the AWS GetCallerIdentity endpoint, including a valid request signature. Workload identity federation refers to this JSON document as GetCallerIdentity token.

  2. The client sends the GetCallerIdentity token to workload identity federation.

  3. Workload identity federation uses the information from the GetCallerIdentity token to perform a GetCallerIdentity request against the AWS API.

    If the request succeeds, workload identity federation knows that the request signature in the GetCallerIdentity token must have been valid – which implies that our AWS credentials must have been valid too.

    From the response, workload identity federation also learns who we are, which is all it needs to know to complete a credential exchange.

Great, but how do you create a signed request, without actually sending it to the AWS endpoint? Or more specifically, how can we generate the right headers for a request? After all, that’s not exactly what the AWS client libraries are designed for…

Creating signature in Python

In Python, signing a request without executing it is surprisingly easy thanks to the SigV4Auth function, which lets us sign an AWSRequest object:

import urllib
import json
import boto3
from botocore.awsrequest import AWSRequest
from botocore.auth import SigV4Auth

# Prepare a GetCallerIdentity request.
request = AWSRequest(
  method="POST",
  url="https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15",
  headers={
    'Host': 'sts.amazonaws.com'
  })

# Sign the request
SigV4Auth(boto3.Session().get_credentials(), "sts", "us-east-1").add_auth(request)

# Get the dict of signed headers
signed_headers = request.headers

The resulting signed_headers dict contains the headers we’re looking for, namely Authorization and x-amz-date. If we used temporary AWS credentials, the dict also contains the extra x-amz-security-token header.

Creating signature in C#

In C#, things are a bit more difficult: The AWSSDK.Core package contains a rich set of authentication-reated utility classes, including the (undocumented) AWS4Signer class. This class lets us sign a request, but we have to pass an IRequest object, which is difficult to synthesize.

Fortunately, AWS4Signer isn’t sealed, so we can derive a class that provides a more convenient way to create signed headers while reusing the existing logic of AWS4Signer:

private class RequestSigner : AWS4Signer
{
  /// <summary>
  /// Create signed header for AWS API request using the v4 signature
  /// method. Cf https://docs.amazonaws.cn/en_us/AmazonS3/latest/API/sig-v4-header-based-auth.html.
  /// </summary>
  internal IDictionary<string, string> CreateHeadersForSignedRequest(
    ImmutableCredentials credential,
    Uri endpoint,
    string region,
    string service,
    IEnumerable<KeyValuePair<string, string>> parameters,
    IEnumerable<KeyValuePair<string, string>> headers,
    string resourcePath,
    string httpMethod,
    byte[] body)
  {
    //
    // Take the original headers and add signature headers.
    //

    var enrichedHeaders = new Dictionary<string, string>(headers);
    DateTime signedAt = InitializeHeaders(enrichedHeaders, endpoint);

    var sortedHeaders = SortAndPruneHeaders(enrichedHeaders);
    var bodyHash = AWSSDKUtils.ToHex(
      CryptoUtilFactory.CryptoInstance.ComputeSHA256Hash(
        body),
        true);

    var signature = ComputeSignature(
      credential.AccessKey,
      credential.SecretKey,
      region,
      signedAt,
      service,
      CanonicalizeHeaderNames(sortedHeaders),
      CanonicalizeRequest(
        endpoint,
        resourcePath,
        httpMethod,
        sortedHeaders,
        CanonicalizeQueryParameters(parameters),
        bodyHash),
      null);

    enrichedHeaders["Authorization"] = signature.ForAuthorizationHeader;
    return enrichedHeaders;
  }
}

Now all we have to do is feed the right set of parameters into CreateHeadersForSignedRequest to get the set of signed headers in return. For example:

ImmutableCredential awsCredential = ...

var endpoint = new Uri("https://sts.us-east-1.amazonaws.com");
var queryParameters = new Dictionary<string, string>
{
  { "Action", "GetCallerIdentity" },
  { "Version", "2011-06-15" }
};

var headers = new Dictionary<string, string>()
if (!string.IsNullOrEmpty(awsCredential.Token))
{
  headers["x-amz-security-token"] = awsCredential.Token;
}

var signedHeaders = new RequestSigner().CreateHeadersForSignedRequest(
  awsCredential,
  endpoint,
  "us-east-1",
  "sts",
  queryParameters,
  headers,
  endpoint.AbsolutePath,
  "POST",
  Array.Empty<byte>());

That’s quite a bit more code than we need in Python, but the end result is the same: signedHeaders contains the request headers we’re looking for, namely Authorization and x-amz-date. If we used temporary AWS credentials, the dict also contains the extra x-amz-security-token header.

Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home