Skip to content

Instantly share code, notes, and snippets.

@anandkunal
Last active May 30, 2024 15:36
Show Gist options
  • Save anandkunal/b67eb94454b77cfc2b50026989586cc0 to your computer and use it in GitHub Desktop.
Save anandkunal/b67eb94454b77cfc2b50026989586cc0 to your computer and use it in GitHub Desktop.
AWS SigV4 & SES Walkthrough in Go

AWS SigV4 & SES Walkthrough in Go

A few years ago, I helped a non-profit organization build and deploy a series of web applications and micro-services on top of AWS. One of the services was responsible for sending out email notifications to people via AWS SES.

Back then, I wrote a really simple program in Go that made direct calls to the AWS API. Instead of using an official library, I read the API specification, learned how to authenticate requests, and implemented a fairly trivial SDK. The code was succinct, readable, and worked perfectly for 3 years without any issues.

That is until this past week…

AWS recently updated the specification, we’re now at Signature Version 4 (SigV4), for how API requests must be formed and signed by clients. In this walkthrough, I’ll share the full Go source code (~130 LOC) that I put together to make valid authenticated calls to AWS for SES. You don’t need to be knowledgeable about Go to grok code in this post. In fact, code in this post will translate pretty cleanly to most languages - even the esoteric ones. The specification I’ll be following and writing code to can be found here: Signature Version 4 signing process - Amazon General Reference.

Let’s start by walking through properties that I established at global scope:

// AWS Configuration
const awsAccessKeyID = ""
const awsSecretAccessKey = ""
const awsSESRegion = "us-west-2"

For the AWS initiated, the above configuration should make sense. For those new to the game, you’ll need to fill in the appropriate access and secret keys as well as the AWS region that you’ll be using for SES. It’s important to mention that this is just an example program and you should not have your AWS access and secret keys embedded in your code. There are better ways to handle the security of these credentials.

Let’s start with main, the program’s entry point:

func main() {
	from, to, subject := "[email protected]", "[email protected]", "Test"
	body := fmt.Sprintf("Hi %s!", to)
	s, err := mail(from, to, subject, body)
	fmt.Println(s, err)
}

We’re keeping things simple for our local mail func and only asking for the sender, recipient, subject, and body. The func signature is:

func mail(from, to, subject, body string) (string, error)

The returned string will be the body of the AWS API HTTP request and error will be any error that we encounter on the way. In the development of this func, we’ll need to add 2 more supporting functions for deriving the signing key, but we’ll get there in a bit. With this boilerplate out of the way, let’s dive deep into the mail func, the heart of this program.

func mail(from, to, subject, body string) (string, error) {
    data := make(url.Values)
    data.Add("Action", "SendEmail")
    data.Add("Source", from)
    data.Add("Destination.ToAddresses.member.1", to)
    data.Add("Message.Subject.Data", subject)
    data.Add("Message.Body.Text.Data", body)
    data.Add("AWSAccessKeyId", awsAccessKeyID)

    encodedData := data.Encode()
    reqBody := strings.NewReader(encodedData)

    sesHost := fmt.Sprintf("email.%s.amazonaws.com", awsSESRegion)
    sesEndpoint := fmt.Sprintf("https://%s", awsSESRegion)

    req, err := http.NewRequest("POST", sesEndpoint, reqBody)
    if err != nil {
        return "", err
    }

    // ...

At the top of the function, we build up a list of value that will eventually be form-urlencoded. I used all of the parameters defined above in my older library, which maps to the SES API, and did not have to make any changes in the move to SigV4. I’ve defined the sesHost and sesEndpoint variables - they’ll be re-used later when we build out the canonical request for signing.

There are 4 tasks to building an authenticated AWS API request:

  1. Creating a canonical request
  2. Creating a string to sign
  3. Computing the signature
  4. Adding the signature to the HTTP request

The rest of this post will break down each of these steps and share the relevant Go code for each of these. At the end, I’ll share the entire Go mail function. If you’re looking for how it all comes together, skip to the bottom of this post.

Task 1: Creating a canonical request

The corresponding task specification can be found here: Task 1: Create a canonical request for Signature Version 4 - Amazon General Reference

This portion is fairly straightforward: we want to generate a canonical request that adheres to the following specification:

CanonicalRequest =
  HTTPRequestMethod + '\n' +
  CanonicalURI + '\n' +
  CanonicalQueryString + '\n' +
  CanonicalHeaders + '\n' +
  SignedHeaders + '\n' +
  HexEncode(Hash(RequestPayload))

Here’s the Go source code for this specific task:

// Now
now := time.Now().UTC()
date := now.Format("20060102T150405Z")

// Canonical Headers
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("host", sesHost)
req.Header.Set("x-amz-date", date)

// Headers
canonicalHeaders := fmt.Sprintf("host:%s\nx-amz-date:%s\n", sesHost, date)
signedHeaders := "host;x-amz-date"

// Compute Payload Hash
payloadHash := hashAndEncode(encodedData)

// Canonical Request
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
    "POST", "/", "", canonicalHeaders, signedHeaders, payloadHash)

In the source code above, we get there by executing the following steps:

  1. Generate a date stamp that will used in a header as well as the canonical request
  2. Set the simple headers: Content-Type, host, and x-amz-date; some languages/frameworks automatically set the host header but I’m adding it here in case yours does not
  3. Compute the payload hash of the SES parameters that we’ve already encoded for the HTTP body
  4. Generate the canonical request string, which we’ll use in the following steps

I wrote a simple function for SHA256 hashing and returning the hex encoded string:

func hashAndEncode(s string) string {
    h := sha256.New()
    h.Write([]byte(s))
    return hex.EncodeToString(h.Sum(nil))
}

Task 2: Creating a string to sign

The corresponding task specification can be found here: Task 2: Create a string to sign for Signature Version 4 - Amazon General Reference

In this portion we’re trying to generate a string to sign that meets the following specification:

StringToSign =
    Algorithm + \n +
    RequestDateTime + \n +
    CredentialScope + \n +
    HashedCanonicalRequest

Here’s the Go source code for this specific task:

// Compute Canonical Request Hash
canonicalRequestHash := hashAndEncode(canonicalRequest)

// Signing String
algorithm := "AWS4-HMAC-SHA256"
credentialDate := now.Format("20060102")
credentialScope := fmt.Sprintf("%s/%s/ses/aws4_request",
    credentialDate, awsSESRegion)
stringToSign := fmt.Sprintf("%s\n%s\n%s\n%s",
    algorithm, date, credentialScope, canonicalRequestHash)

In the source code above, we get there by executing the following steps:

  1. Compute the canonical request hash
  2. Initialize and format variables for the string to sign; note, the credential date is formatted differently from the x-amz-date header
  3. Generate the string to sign, which we’ll use in the following steps

Now we’re off to task 3 - arguably the most interesting of all the tasks that we’ve discussed here.

Task 3: Computing the signature

The corresponding task specification can be found here: Task 3: Calculate the signature for Amazon Signature Version 4 - Amazon General Reference

For this task, we’re generate a signing key based on the following specification:

kSecret = your secret access key
kDate = HMAC("AWS4" + kSecret, Date)
kRegion = HMAC(kDate, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")

Unlike the previous 2 tasks, we’ll break this up into 2 parts. I’ll start with sharing the bits from our mail function to generate the signature. Here’s the Go source code:

// Compute Signature
signingKey := getSignatureKey(awsSecretAccessKey, credentialDate,
    awsSESRegion, "ses")
signatureSHA := hmac.New(sha256.New, signingKey)
signatureSHA.Write([]byte(stringToSign))
signatureString := hex.EncodeToString(signatureSHA.Sum(nil))

Now, here’s the Go code for getSignatureKey and sign:

func sign(key []byte, message string) []byte {
    h := hmac.New(sha256.New, key)
    h.Write([]byte(message))
    return h.Sum(nil)
}

func getSignatureKey(key, dateStamp, region, service string) []byte {
    kSecret := []byte(fmt.Sprintf("AWS4%s", key))
    kDate := sign(kSecret, dateStamp)
    kRegion := sign(kDate, region)
    kService := sign(kRegion, service)
    kSigning := sign(kService, "aws4_request")
    return kSigning
}

You’ll notice a few hardcoded elements based on the spec, like AWS4 prefixing the secret access key or aws4_request as the final element in signing the message.

Now, we’ll head to the final task, adding the signature to the request.

Task 4: Adding the signature to the HTTP request

The corresponding task specification can be found here: Task 4: Add the signature to the HTTP request - Amazon General Reference

We’re ultimately building a value for an HTTP Authorization header that meets the following specification:

Authorization: algorithm Credential=access key ID/credential scope, SignedHeaders=SignedHeaders, Signature=signature

Here’s the Go source code for this specific task:

// Set Authorization Header
authorizationHeader := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", algorithm, awsAccessKeyID, credentialScope, signedHeaders, signatureString)
req.Header.Set("Authorization", authorizationHeader)

There you have it! These four tasks make up all the relevant bits to make an authenticated AWS API request to send an email with SES.

Final Source & Parting Thoughts

The final source code for this program can be found here: https://gist.github.com/anandkunal/b67eb94454b77cfc2b50026989586cc0

I had a ton of fun putting this together and learned more about AWS’ latest specification. While I could have used the official AWS Go module to get the job done quickly and efficiently, I found this to be a really great way to learn a new protocol. I hope this helps those that are upgrading their scripts or curious to see what it looks like to implement AWS SigV4 in Go.

Appendix & Additional Reading

AWS has solid documentation. Aside from the links that were already mentioned above, I relied on the following to help me get this program built:

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"strings"
"time"
)
// AWS Configuration
const awsAccessKeyID = "access key goes here"
const awsSecretAccessKey = "secret access key goes here"
const awsSESRegion = "us-west-2"
func hashAndEncode(s string) string {
h := sha256.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
func sign(key []byte, message string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(message))
return h.Sum(nil)
}
func getSignatureKey(key, dateStamp, region, service string) []byte {
kSecret := []byte(fmt.Sprintf("AWS4%s", key))
kDate := sign(kSecret, dateStamp)
kRegion := sign(kDate, region)
kService := sign(kRegion, service)
kSigning := sign(kService, "aws4_request")
return kSigning
}
func mail(from, to, subject, body string) (string, error) {
data := make(url.Values)
data.Add("Action", "SendEmail")
data.Add("Source", from)
data.Add("Destination.ToAddresses.member.1", to)
data.Add("Message.Subject.Data", subject)
data.Add("Message.Body.Text.Data", body)
data.Add("AWSAccessKeyId", awsAccessKeyID)
encodedData := data.Encode()
reqBody := strings.NewReader(encodedData)
sesHost := fmt.Sprintf("email.%s.amazonaws.com", awsSESRegion)
sesEndpoint := fmt.Sprintf("https://%s", awsSESRegion)
req, err := http.NewRequest("POST", sesEndpoint, reqBody)
if err != nil {
return "", err
}
// Now
now := time.Now().UTC()
date := now.Format("20060102T150405Z")
// Canonical Headers
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("host", sesHost)
req.Header.Set("x-amz-date", date)
// Headers
canonicalHeaders := fmt.Sprintf("host:%s\nx-amz-date:%s\n", sesHost, date)
signedHeaders := "host;x-amz-date"
// Compute Payload Hash
payloadHash := hashAndEncode(encodedData)
// Canonical Request
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
"POST", "/", "", canonicalHeaders, signedHeaders, payloadHash)
// Compute Canonical Request Hash
canonicalRequestHash := hashAndEncode(canonicalRequest)
// Signing String
algorithm := "AWS4-HMAC-SHA256"
credentialDate := now.Format("20060102")
credentialScope := fmt.Sprintf("%s/%s/ses/aws4_request",
credentialDate, awsSESRegion)
stringToSign := fmt.Sprintf("%s\n%s\n%s\n%s",
algorithm, date, credentialScope, canonicalRequestHash)
// Compute Signature
signingKey := getSignatureKey(awsSecretAccessKey, credentialDate,
awsSESRegion, "ses")
signatureSHA := hmac.New(sha256.New, signingKey)
signatureSHA.Write([]byte(stringToSign))
signatureString := hex.EncodeToString(signatureSHA.Sum(nil))
// Set Authorization Header
authorizationHeader := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", algorithm, awsAccessKeyID, credentialScope, signedHeaders, signatureString)
req.Header.Set("Authorization", authorizationHeader)
r, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("http error: %s", err)
return "", err
}
resultbody, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
if r.StatusCode != 200 {
log.Printf("error, status = %d", r.StatusCode)
log.Printf("error response: %s", resultbody)
return "", fmt.Errorf("error code %d. response: %s", r.StatusCode, resultbody)
}
return string(resultbody), nil
}
func main() {
from, to, subject := "[email protected]", "[email protected]", "Test"
body := fmt.Sprintf("Hi %s!", to)
s, err := mail(from, to, subject, body)
fmt.Println(s, err)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment