home / 2017.03.30 16:00 /aws /api gateway /iam /sigv4

Signing requests with Amazon IAM credentials

This document explores the process of accessing services exposed through Amazon’s API Gateway that are secured with IAM credentials instead of Cognito tokens. The mechanism for getting access to those services is based on signing the request using our secret key instead of actually sending the key over. When accessing Amazon services through the SDK, the SDK takes care of this signing process for you, but if you want to access your own custom services exposed through API Gateway you will have to write your own code to sign that request, and this is quite an involved process.

Creating a mock service and exposing it throguh the API Gateway

First we need a service we want to access. I’ve created a simple javascript Lambda that returns some JSON data (I named it getUserForms):

exports.handler = (event, context, callback) => {
    var result = {
        'fields': [
            { 'name': 'family name', 'value': ''},
            { 'name': 'first name', 'value': ''},
            { 'name': 'address', 'value': ''},
            { 'name': 'postcode', 'value': ''}
        ]
    }
    callback(null, result);
};

Next, I make this service accessible through API Gateway by creating a new resource and a GET method invoking the Lambda. Once the API is published to staging, we can invoke it to obtain our data with some very simple javascript code:

var https = require('https');
var bl = require('bl');

function collecter(callback) {
    return function(result) {
        function onceCollected(err, data) {
            var string = data.toString();
            callback(string);
        }
        result.setEncoding('utf8');
        result.pipe(bl(onceCollected));
        result.on('error', console.error);
    }
}

function loadForms(callback) {
    var options = {
            method: 'GET',
            host: 'staging url',
            path: '/cognitotest/forms'
    };
    var request = https.request(options, collecter(callback));
    request.end()
}

loadForms(function(result) {
    console.log(result);
});

I’ve added this code to a file test.js. Running it on node (with the appropriate dependencies available), we see the data getting printed on screen. Now it’s time to secure this endpoint. Go back to the API Gateway console, on your forms resource and the GET method and click the method request section. Under authorization settings select AWS_IAM for the authorization field and click the small check sign to confirm (small side note: the role corresponding to the credentials you will use to connect to the API needs to have permissions to execute API Gateway endpoints, and you can take care of that in the IAM console). Deploy to staging and run the test.js script again and you should see the following message:

{"message":"Missing Authentication Token"}

Signing a request

Signing a request is a complicated process. I have added relevant links about this at the end of the document, but I will go over the process and its implementation in the following sections, step by step, providing a library that can be used for signing almost any requests (there are still some parts I have not addressed).

The process for singing a request are:

These steps would work on the options object used to make the request. Besides the options object, other inputs will be

The following signRequest is the main method which will handle the whole process:

function signRequest(options, requestBody, accessKeyID, secretKey, region, service, sessionToken) {
    formatHeaders(options, sessionToken);

    var canonicalRequestAndHash = toCanonicalRequestAndHash(options, requestBody)
    var stringToSign = createStringToSign(options, canonicalRequestAndHash.rehashedCanonicalRequest, region, service)
    var signingKey = createSigningKey(options, secretKey, region, service);
    var signature = calculateSignature(signingKey, stringToSign.stringToSign)
    var authorization = createAuthorizationHeader(accessKeyID, stringToSign.credentialsScope, canonicalRequestAndHash.signedHeaders, signature);

    options.headers['Authorization'] = authorization;
}

Formatting headers

A signed request to API Gateway will require an amazon date header and the host header. The session token will also need to be included in a header if we have one.

function formatHeaders(options, sessionToken) {
    if (options.headers == undefined) {
        options.headers = {}
    }
    if (options.headers[AMAZON_DATE_HEADER] == undefined) {
        options.headers[AMAZON_DATE_HEADER] = getISO8601UTC();
    }
    if (options.headers[HOST_HEADER] == undefined) {
        options.headers[HOST_HEADER] = options.host;
    }
    if (sessionToken) {
        options.headers[AMAZON_SESSION_TOKEN_HEADER] = sessionToken;
    }
}

The date itself needs to be the UTC date in ISO8601 format, and I compute it with the following (very unrefined) code:

function getISO8601UTC() {
    var today = new Date();
    var string = ''
    string += today.getUTCFullYear()
    string += withLeadingZero((today.getUTCMonth() + 1))
    string += withLeadingZero(today.getUTCDate())
    string += 'T'
    string += withLeadingZero(today.getUTCHours())
    string += withLeadingZero(today.getUTCMinutes())
    string += withLeadingZero(today.getUTCSeconds())
    string += 'Z'
    return string;
}

function withLeadingZero(value) {
    if (value < 10) {
        return '0' + value;
    } else {
        return value;
    }
}

The canonical request

The Amazon documentation tells us the canonical request is just a very text representation of the request in the following form:

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

Hash is a function that produces a message digest, typically SHA-256, HexEncode returns a base-16 encoding of the digest in lowercase characters. My implementation of this follows:

function toCanonicalRequestAndHash(options, requestBody) {
    var canonicalRequest = '';
    canonicalRequest += options.method + '\n';
    canonicalRequest += canonicalURI(options);
    canonicalRequest += canonicalQueryString(options);
    var canonicalHeaders = canonicalHeadersAndSignedHeaders(options);
    canonicalRequest += canonicalHeaders.combined
    var canonicalRequestHash = hashAndHexEncode(requestBody)
    canonicalRequest += canonicalRequestHash;
    var rehashedCanonicalRequest = hashAndHexEncode(canonicalRequest);

    return {
        'canonicalRequest': canonicalRequest,
        'canonicalRequestHash': canonicalRequestHash,
        'signedHeaders': canonicalHeaders.signedHeaders,
        'rehashedCanonicalRequest': rehashedCanonicalRequest
    };
}

Next, let’s visit the individual methods, starting with canonicalURI:

function canonicalURI(options) {
    var path = options.path;
    var parametersStart = path.indexOf("?");
    if (parametersStart > -1) {
        path = path.substring(0, parametersStart)
    }
    if (path && path.length > 0) {
        return encodeURI(path) + '\n';
    } else {
        return encodeURI('/') + '\n';
    }
}
function canonicalQueryString(options) {
    // todo: implement
    return '\n';
}
function canonicalHeadersAndSignedHeaders(options) {
    var sList = sortedList(options.headers);
    var cHeaders = '';
    var sHeaders = '';
    for (var i = 0; i < sList.length; i++) {
        cHeaders += sList[i].name + ':' + sList[i].value + '\n'
        sHeaders += ';' + sList[i].name;
    }
    if (cHeaders.length == 0) {
        // probably will never occur since the host header always needs to be included
        cHeaders += '\n';
    }
    sHeaders = sHeaders.substring(1)

    return {
        'canonicalHeaders': cHeaders,
        'signedHeaders': sHeaders,
        'combined': (cHeaders + '\n' + sHeaders + '\n')
    }
}

function sortedList(map) {
    var list = []
    for (name in map) {
        list.push({'name': lowercase(name), 'value': trimall(map[name])});
    }
    list.sort(function(a, b) {
        return a.name.localeCompare(b.name);
    });
    return list;
}

function lowercase(value) {
    return value.toLowerCase();
}

function trimall(value) {
    return value.trim().replace(new RegExp(/\s+/gi), " ");
}
function hashAndHexEncode(requestBody) {
    var sha256 = crypto.createHash("sha256");
    var hashObject = sha256.update(requestBody || '', 'utf8');
    var hex = hashObject.digest('hex').toLowerCase();
    return hex;
}

The string to sign

The algorithm (in pseudocode) for creating a string to sign is:

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

The createStringToSign method follows this algorithm very closely, but the return object of this method contains two fields, one the string to sign and the other the credential scope, because we will need both fields later.

function createStringToSign(options, canonicalRequestHash, region, service) {
    var stringToSign = '';
    stringToSign += 'AWS4-HMAC-SHA256' + '\n' // corresponds to SHA256
    stringToSign += options.headers[AMAZON_DATE_HEADER] + '\n'
    var credentialsScope = createCredentialsScope(options, region, service)
    stringToSign += credentialsScope + '\n'
    stringToSign += canonicalRequestHash;
    return {
        'stringToSign': stringToSign,
        'credentialsScope': credentialsScope
    }
}

function createCredentialsScope(options, region, service) {
    var date = options.headers[AMAZON_DATE_HEADER].substring(0, 8);
    return date + '/' + region + '/' + service + '/aws4_request'
}

Getting the signing key

The signing key is created by following this algorithm:

kSecret = secret access key
kDate = HMAC("AWS4" + kSecret, Date)
kRegion = HMAC(kDate, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")
function createSigningKey(options, secret, region, service) {
    var date = options.headers[AMAZON_DATE_HEADER].substring(0, 8);

    var kDate = HMAC("AWS4" + secret, date)
    var kRegion = HMAC(kDate, region)
    var kService = HMAC(kRegion, service)
    var kSigning = HMAC(kService, "aws4_request")
    return kSigning
}

function HMAC(original, update) {
    return crypto.createHmac('sha256', original).update(update).digest();
}

Computing the signature

We now have all we need to compute the signature, with the calculateSignature method. We are signing the string to sign with the signing key we obtained before:

function calculateSignature(signingKey, stringToSign) {
    return crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex').toLowerCase();
}

Building the authorization header

function createAuthorizationHeader(accessKeyID, credentialsScope, signedHeaders, signature) {
    var authorization = '';
    authorization += 'AWS4-HMAC-SHA256' + ' ';
    authorization += 'Credential=' + accessKeyID + '/' + credentialsScope + ', ';
    authorization += 'SignedHeaders=' + signedHeaders + ', '
    authorization += 'Signature=' + signature
    return authorization;
}

Last thing we need to do is export the signRequest method and we can use our library to make signed calls to API Gateway:

exports.signRequest = signRequest

Sending a signed request

In the test.js I created another method, loadFormsWithAuth that will use our library to sign the request before sending it to API Gateway:

var signature = require('./signature.js');

function loadFormsWithAuth(callback) {
    var options = {
            method: 'GET',
            host: 'api gateway url',
            path: '/cognitotest/forms'
    };
    // build the signature
    var accessKeyId = 'access key id'
    var secretKey = 'secret key'
    var sessionToken = 'session token'
    signature.signRequest(options, '', accessKeyId, secretKey, 'region', 'execute-api', sessionToken);


    var request = https.request(options, collecter(callback));
    request.end()
}

loadFormsWithAuth(console.log);

Running the code, you should now have access to the secured API and see the following output on the console:

{ fields:
   [ { name: 'family name', value: '' },
     { name: 'first name', value: '' },
     { name: 'address', value: '' },
     { name: 'postcode', value: '' } ] }

References