20

Over the past few days I have been struggling to create the AWS signature version 4 in Apex to send a request via query string, I have followed the step-by-step instructions for signing aws requests, regardless I'm getting the error <Code>IncompleteSignature</Code> <Message>Request must contain a signature that conforms to AWS standards</Message>, I don't know where I could have done a mistake, here are the methods (in a class called AWS) I'm using to sign:

AWS

public with sharing abstract class AWS {
    public string secret { get; set; }
    public string key { get; set; }

    public Blob createCanonicalRequest(...){...}
    public String getStringToSign(...){...}
    public String signature(...){...}
}

createCanonicalRequest

public Blob createCanonicalRequest(String method, String queryString, Map<String, String> headers, String payload){
    String canonicalRequest = method + '\n';
    canonicalRequest += '/' + '\n';
    canonicalRequest += queryString + '\n';

    List<String> headersList = new List<String>(headers.keySet());
    headersList.sort();

    for (String header : headersList){
        String headerContent = header.toLowerCase() + ':' + headers.get(header).trim();
        canonicalRequest += headerContent + '\n';
    }

    for (String header : headersList){
        canonicalRequest += header.toLowerCase() + ';';
    }

    canonicalRequest = canonicalRequest.removeEnd(';');
    canonicalRequest += '\n';
    canonicalRequest += EncodingUtil.convertToHex(Crypto.generateDigest('SHA256', Blob.valueOf(payload))).toLowerCase();

    return Crypto.generateDigest('SHA256', Blob.valueOf(canonicalRequest));
}

getStringToSign

public String getStringToSign(Blob canonicalRequest, Datetime requestdate, String region, String service){
    String stringToSign = 'AWS4-HMAC-SHA256' + '\n';

    String formatedDate = requestdate.formatGmt('yyyyMMdd') + 'T' + requestdate.formatGmt('hhmmss') + 'Z';
    stringToSign += formatedDate + '\n';
    stringToSign += requestdate.formatGmt('yyyyMMdd') + '/' + region + '/' + service + '/aws4_request\n'; 
    //stringToSign += String.valueOf(canonicalRequest);
    stringToSign += EncodingUtil.base64Encode(canonicalRequest);

    return stringToSign;
}

signature

public String signature(String secret, String today, String region, String service, String stringToSign){
    Blob privateKey = Crypto.generateMac('HmacSHA256', Crypto.generateMac('HmacSHA256', Crypto.generateMac('HmacSHA256', Crypto.generateMac('HmacSHA256', 
                        Blob.valueOf(today), Blob.valueOf('AWS4' + secret)), 
                            Blob.valueOf(region)), 
                                Blob.valueOf(service)), 
                                    Blob.valueOf('aws4_request'));
    return EncodingUtil.convertToHex(Crypto.generateMac('HmacSHA256', Blob.valueOf(stringToSign), privateKey));
}

And I'm using them to create a topic in SNS like this:

public with sharing class SNS extends AWS {
    String region = 'eu-west-1';
    String endpoint = 'http://sns.' + region + '.amazonaws.com';
    String method = 'GET';
    String service = 'sns';

    public SNS(String key, String secret) {
        this.key = key;
        this.secret = secret;
    }

    public HttpResponse createTopic(String topicName){
        HttpRequest request = new HttpRequest();
        request.setMethod(method);
        request.setEndpoint(endpoint);

        request.setBody(getCreateTopicParameters(topicName));

        Http http = new Http();

        try {
            return http.send(request);;
        } catch (System.CalloutException e) {
            throw e;
        }
    }

    private String getCreateTopicParameters(String topicName){
        String body = 'Name=' + topicName;
        body += '&Action=CreateTopic';
        body += '&SignatureMethod=HmacSHA256';
        body += '&AWSAccessKeyId=' + this.key;
        body += '&SignatureVersion=4';

        String signature = getSignature(body);

        body += '&Signature=' + signature;
        return body;
    }

    private String getSignature(String body){
        Datetime now = Datetime.now();
        String today = now.formatGmt('yyyyMMdd');
        Map<String,String> headers = new Map<String,String>();
        headers.put('host',endpoint);

        Blob canonicalRequest = createCanonicalRequest(method, body, headers,'Action=CreateTopic');
        String stringToSign = getStringToSign(canonicalRequest, now, region, service);
        String signature = signature(secret, today, region, service, stringToSign);
        return signature;
    }
}

If someone can point out where I went wrong, I would really appreciate it.

  • 1
    It would be awesome if you could create this as a gist. – Charles Koppelman Aug 18 '15 at 22:16
  • Hold on. I have a working version here somewhere... – sfdcfox Aug 31 '15 at 16:05
15
+50

Here's a gist that shows the basic framework that I developed. To actually implement a sub-service using this framework, you simply need a couple of extra steps, outlined here:

public class AWSS3_GetService extends AWS {
    public override void init() {
        endpoint = new Url('https://s3.amazonaws.com/');
        resource = '/';
        region = 'us-east-1';
        service = 's3';
        accessKey = 'my-key-here';
        method = HttpMethod.XGET;
        //  You can specify "payload" here if a body is required
        //  payload = Blob.valueof('some-text');

        //  This method helps prevent leaking secret key, 
        //  as it is never serialized
        createSigningKey('my-secret-key-here');
   }
   public String[] getBuckets() {
       HttpResponse response = sendRequest();
       String[] results = new String[0];
       // Read response XML; if we get this far, no exception happened
       // This code was omitted for brevity
       return results;
   }
}

Edit: Your code looks "similar" to mine, but there's a ton of potential pitfalls. I see you're not sorting your query string correctly (as far as I can tell), and you're using the query-string version of the authorization code (which you'll see in my code is implemented as the Authorization header). The Signature query parameter itself is probably malformed; it took me quite a bit of effort (several days, in fact), to get AWS to accept a valid request from me.

  • Awesome, thanks sfdcfox. I haven't get around to test it (way too busy last few days) yet, but I'm sure it works, so I'm going to go ahead and accept your answer. – Jose Sep 2 '15 at 13:35
  • Great, it works. Just one thing, I was getting this error Date must be in ISO-8601 'basic format' so I had to changed the format from 'E, dd MMM YYYY HH:mm:ss z' to 'YYYYMMdd\'T\'HHmmss\'Z\'' and then it worked. I'm not sure if this an AWS wide changed, or is just for the SNS service. I'm going with the latter. Thanks again! – Jose Sep 4 '15 at 16:37
  • @Jose That's odd. It might be specific to that service. The "date" header is supposed to be the standard HTTP-style header. It might be related to just SNS. Thanks for that heads-up, though, because I might need to use SNS at some point. – sfdcfox Sep 4 '15 at 16:59
  • Is this possible to use Named Credentials or other means to store accessKey and secretKey in safe place? – Patlatus Jan 2 '18 at 13:01
  • @Patlatus Named Credentials doesn't really work here, because you need access to those values. I would probably consider using a Custom Setting and encrypt the values using the Crypto class. – sfdcfox Jan 2 '18 at 16:17
4

The date formatting is wrong in the method "getStringToSign". The day and month must be in 2 digits, and same for hour, minutes...

You should debug your code to ensure the string to sign is in the right format (especially the date/time):

http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html

  • Great catch, thanks! I just updated the question with the correct formatting but I still keep getting the same error. – Jose Aug 18 '15 at 19:38
  • @Jose Community etiquette is to upvote well thought out questions and answers if someone has sufficient reputation. While the above user may not have completely solved your issue, they invested time in pointing you in the right direction to solving it by catching an error in your code that you've acknowledged. In my view, that would at least merit an upvote from you which I don't see. +1 from me. – crmprogdev Sep 1 '15 at 17:32
1

Try this getStringToSign method. I've changed the second line from the bottom.

public String getStringToSign(Blob canonicalRequest, Datetime requestdate, String region, String service){
String stringToSign = 'AWS4-HMAC-SHA256' + '\n';

String formatedDate = requestdate.formatGmt('yyyyMMdd') + 'T' + requestdate.formatGmt('hhmmss') + 'Z';
stringToSign += formatedDate + '\n';
stringToSign += requestdate.formatGmt('yyyyMMdd') + '/' + region + '/' + service + '/aws4_request\n'; 
//stringToSign += String.valueOf(canonicalRequest);
// stringToSign += EncodingUtil.base64Encode(canonicalRequest); COMMENTED OUT THIS LINE AND REPLACED WITH THE ONE BELOW
stringToSign += EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', canonicalRequest));


return stringToSign;
}
  • Hi Joe, thanks for the input. However, why are you parsing canonicalRequest, already a Blob value, to Blob? That line throw me an error, I tried it like stringToSign += EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', canonicalRequest)); but I keep getting the same error. – Jose Sep 2 '15 at 13:36
  • my bad! I'd modified that line based on the class we are using in our org without noticing that your code already has canonicalRequest as a blob. The main change I was trying to highlight is to replace EncodingUtil.base64Encode with EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256' – joe Sep 2 '15 at 13:45
  • I've now edited my answer to take this into account – joe Sep 2 '15 at 13:48

Not the answer you're looking for? Browse other questions tagged or ask your own question.