'Aws4 sign S3 PUT requests

Writing specialized S3 file upload request signing function that will run on Cloudflare workers (I guess should be the same as in browsers):

let s3PutSign = function(region, keyId, keySecret, contentType, date, bucket, fileName) {
    return crypto.subtle.importKey('raw', new TextEncoder().encode(keySecret), { name: 'HMAC', hash: 'SHA-256' }, true, ['sign'])
        .then(key => {
            let path = `/${bucket}/${fileName}`
            let strToSign = `PUT\n\n${contentType}\n${date}\n${path}`
            return crypto.subtle.sign('HMAC', key, new TextEncoder().encode(strToSign))
                .then(sig => {
                    return {
                        url: `https://s3.${region}.amazonaws.com${path}`,
                        headers: { 
                            'content-type': contentType,
                            'Authorization': `AWS ${keyId}:${btoa(sig)}`,
                            'x-amz-date': new Date(new Date().getTime() + 10000).toISOString().replace(/[:\-]|\.\d{3}/g, '').substr(0, 17)
                        }
                    }
                })
        })
}

Wrote function using PUT example: https://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html

Variable strToSign:

PUT

application/pdf
Wed, 27 May 2020 12:26:33 GMT
/mybucket/file.pdf

function result:

{
  url: "https://s3.eu-central-1.amazonaws.com/mybucket/file.pdf",
  headers: {
    content-type: "application/pdf",
    Authorization: "AWS AKXAJE7XIIVXQZ4X7FXQ:W29iamVXZCBBcnJheUJ1ZmZlcl0=",
    x-amz-date: "20200527T122643Z"
  }
}

Requests always result this response:

<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>InvalidRequest</Code>
  <Message>The authorization mechanism you have provided is not supported. Please use AWS4-HMAC-SHA256.</Message>
  <RequestId>7CECC87D5E855C48</RequestId>
  <HostId>rtGLR0u9Qc29bllgKnJf7xD00iQ0+/BZog5G/wYWjsN8tkXio9Baq7GZvbQTD40EVCQ9FzuCo9c=</HostId>
</Error>

Please advise how to debug or give a hint what could be wrong with this function.



Solution 1:[1]

Researching this a bit, it seems that AWS4-HMAC-SHA256 may define a specific hashing algorithm. Looking at this (awesome) gist, the author calls out the full algo name.

You might try replacing your call ({ name: 'HMAC', hash: 'SHA-256' }) with { name: 'HMAC', hash: 'AWS4-HMAC-SHA256' }

Another thought is to remove the dash (-) from your algorithm name. Go from SHA-256 to SHA256 and see if that makes a diff.

Solution 2:[2]

I know the post is not new, but maybe it will help someone else. :) Here is a working code example, that I gathered from AWS documentation and some other sources.

// make the call
let signer = new S3Signer(body,'ACCESKEYS','SECRETACCESSKEY','us-east-1','my-test-bucket23.s3.us-east-1.amazonaws.com','/something/SOME/2023/02/some6.pdf','multipart/form-data');
          let signerRes = await signer.makeTheCall();

// implementation
const axios = require('axios');
const crypto = require('crypto');

class S3Signer {
constructor(body, accessKey, secretKey, region, host, path, contentType) {
    this.region = region; //'us-east-1';
    this.host = host; // `my-test-bucket23.s3.us-east-1.amazonaws.com`
    this.method = 'PUT';
    this.service = 's3';
    this.path = path; // `/something/SOME/2023/02/some6.pdf`
    this.url = `https://${this.host}${this.path}`;
    this.contentType = contentType; //'multipart/form-data';
    this.amzDate = null;
    this.body = body;
    //this.bodyUTF8 = body.toString('utf8');
    this.algorithm = 'AWS4-HMAC-SHA256';
    this.credentialScope = '';
    this.signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date';

    this.accessKey = accessKey;
    this.secretKey = secretKey;
}
getSignatureKey = (key, dateStamp, regionName, serviceName) => {
    let kDate = this.hmac(('AWS4' + key), dateStamp);
    let kRegion = this.hmac(kDate, regionName);
    let kService = this.hmac(kRegion, serviceName);
    let kSigning = this.hmac(kService, 'aws4_request');
    return kSigning;
}
makeTheCall = async () => {

    let canonicalRequest = this.createCanonicalReq();

    let signature = this.calculateSignature(canonicalRequest);

    // ************* TASK 4: ADD SIGNING INFORMATION TO THE REQUEST *************
    // Put the signature information in a header named Authorization.
    let authorizationHeader = this.algorithm + ' ' + 'Credential=' + this.accessKey + '/' + this.credentialScope + ', ' + 'SignedHeaders=' + this.signedHeaders + ', ' + 'Signature=' + signature;

    // For DynamoDB, the request can include any headers, but MUST include "host", "x-amz-date",
    // "x-amz-target", "content-type", and "Authorization". Except for the authorization
    // header, the headers must be included in the canonical_headers and signed_headers values, as
    // noted earlier. Order here is not significant.
    //// Python note: The 'host' header is added automatically by the Python 'requests' library.
    let headers = {
        'Authorization': authorizationHeader,
        'Content-Type': this.contentType,
        'X-Amz-Content-Sha256' : this.hash(this.body, 'hex'),
        'X-Amz-Date': this.amzDate
    }
    let request = {
        host: this.host,
        method: this.method,
        url: this.url,
        data: this.body,
        body: this.body,
        path: this.path,
        headers: headers
    }
    
    // send the file to s3
    let res = await axios(request);

    console.log(res);
    return res;

}
calculateSignature = (canonicalReq) => {
    // SHA-256 (recommended)
    let dateStamp = this.amzDate.substring(0, 8);
    this.credentialScope = dateStamp + '/' + this.region + '/' + this.service + '/' + 'aws4_request'
    let stringSign = this.algorithm + '\n' + this.amzDate + '\n' + this.credentialScope + '\n' + this.hash(canonicalReq, 'hex')

    // ************* TASK 3: CALCULATE THE SIGNATURE *************
    // Create the signing key using the function defined above.
    let signingKey = this.getSignatureKey(this.secretKey, dateStamp, this.region, this.service);

    // Sign the string_to_sign using the signing_key
    let signature = this.hmac(signingKey, stringSign, 'hex');

    return signature;
}
createCanonicalReq = () => {
    // ************* TASK 1: CREATE A CANONICAL REQUEST *************
    // http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
    // Step 1 is to define the verb (GET, POST, etc.)--already done.

    // Step 2: Create canonical URI--the part of the URI from domain to query 
    // string (use '/' if no path)
    let canonical_uri = this.path;

    //// Step 3: Create the canonical query string. In this example, request
    // parameters are passed in the body of the request and the query string
    // is blank.
    let canonical_querystring = '';
    ///// set the date
    let date = new Date();
    this.amzDate = date.toISOString().replace(/[:\-]|\.\d{3}/g, '')

    // Step 4: Create the canonical headers. Header names must be trimmed
    // and lowercase, and sorted in code point order from low to high.
    // Note that there is a trailing \n.
    let canonical_headers = 'content-type:' + this.contentType + '\n' + 'host:' + this.host + '\n' 
                          + 'x-amz-content-sha256:' + this.hash(this.body, 'hex') + '\n' 
                          + 'x-amz-date:' + this.amzDate + '\n'

    // Step 5: Create the list of signed headers. This lists the headers
    // in the canonical_headers list, delimited with ";" and in alpha order.
    // Note: The request can include any headers; canonical_headers and
    // signed_headers include those that you want to be included in the
    // hash of the request. "Host" and "x-amz-date" are always required.
    // For DynamoDB, content-type and x-amz-target are also required.
    //this.signedHeaders = 'content-type;host;x-amz-date'

    // Step 6: Create payload hash. In this example, the payload (body of
    // the request) contains the request parameters.
    let payload_hash = this.hash(this.body, 'hex');

    // Step 7: Combine elements to create canonical request
    let canonicalRequest = this.method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + this.signedHeaders + '\n' + payload_hash
    return canonicalRequest;
}
hmac = (key, string, encoding) => {
    return crypto.createHmac('sha256', key).update(string, 'utf8').digest(encoding)
}
hash = (string, encoding) => {
    return crypto.createHash('sha256').update(string, 'utf8').digest(encoding)
}

// This function assumes the string has already been percent encoded
// only if you have reuquest query
encodeRfc3986 = (urlEncodedString) => {
    return urlEncodedString.replace(/[!'()*]/g, (c) => {
        return '%' + c.charCodeAt(0).toString(16).toUpperCase()
    })
}

}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 SeaDude
Solution 2 Oguz