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()
})
}
}