Native AWS S3 Integration in MQL5: A Signature V4 Client for Model and Feature Artifact Storage
Introduction
Most MetaTrader 5 setups that use machine learning end up with the same unglamorous problem sooner or later: the model lives on whichever machine trained it. Retrain on a dev box, deploy on a VPS, and now there are two copies to keep in sync. Add a second VPS for a different symbol or a different broker, and it turns into a manual file-copying exercise nobody wants to own. Feature snapshots exported for offline analysis have the same issue in reverse - they need to get off the terminal and somewhere centralized before anyone can actually use them.
Amazon S3 is the obvious place to put both. It is cheap, versionable, and reachable from anywhere, and every language has an SDK for it - except MQL5. MetaTrader has no AWS library, and S3 will not accept an unsigned request: every call has to carry an AWS Signature Version 4 (SigV4) signature, a specific HMAC-based scheme that authenticates the request without ever putting the secret key on the wire. Building that signer natively means working entirely from what MQL5 actually exposes - CryptEncode() for hashing and WebRequest() for the HTTPS call - with no shortcut and no existing MQL5 implementation to borrow from.
The alternative to building this natively would be an external DLL wrapping a real AWS SDK, but that closes off the EA or script to anyone running MetaTrader 5 in a sandboxed or DLL-restricted environment - a real constraint on a lot of VPS and prop-firm setups. A native signer removes that specific restriction: it compiles without needing DLL imports enabled, at the cost of having to implement HMAC and the canonical-request construction by hand instead of calling into a maintained library. It still depends on WebRequest()'s own allow-list, and on whatever network/TLS policy the terminal or VPS enforces - going native removes the DLL requirement, not every environment constraint.
Scope note: this article covers exactly three operations - uploading an artifact, downloading it back, and listing what is in a bucket. It signs and sends real HTTPS requests to a real S3 bucket; it does not wrap the full S3 API, and it is not a general-purpose AWS SDK.
The client consists of three files. AWS4SignerCore.mqh is the SigV4 signer: canonical request, hand-rolled HMAC-SHA256, and the four-step signing-key derivation chain. S3Client.mqh wraps that signer around WebRequest() to expose PutObject(), GetObject(), and ListObjectsV2(). S3ArtifactSync_Script.mq5 is the test harness: it uploads a local.onnx model and a feature CSV, downloads them back, and compares SHA-256 checksums to confirm a byte-identical round trip. S3 also returns an ETag on a successful PUT, but this client does not capture or compare it.
Architecture
The request flow is a straight pipeline, not a web of callbacks: a script reads local bytes, hands them to a thin S3 client, which asks the signer for an Authorization header, then sends one signed HTTPS call. Nothing here is asynchronous and nothing runs on a timer - it is a linear sequence that starts and finishes within a single OnStart() call.
Request Flow From Local File to Signed S3 Call

Fig. 1. The script reads a local artifact, CS3Client asks CAWS4Signer for a signature, and the signed request goes out over WebRequest().
Each component has a distinct responsibility. CAWS4Signer builds the SigV4 canonical request and signature but performs no I/O: it never calls WebRequest() or touches a file. CS3Client prepares the request-specific inputs (host, canonical URI, timestamps, payload hash), asks the signer for the Authorization header, and calls WebRequest(). S3ArtifactSync_Script.mq5 does not implement SigV4; it reads local bytes and passes its AWS-specific inputs (keys, region, bucket, object key) to CS3Client. The current implementation signs only host, x-amz-date, and x-amz-content-sha256, and does not support x-amz-security-token or service-specific headers such as x-amz-target. Supporting those requires extending the signer interface, not just changing the service code string.
This separation also has a practical payoff for anyone extending the client later: adding a fourth operation - DeleteObject, say, or a multipart upload initiation call - means writing one more method on CS3Client that builds the right canonical URI and query string and calls the same SignAndBuildAuthHeader(), without touching AWS4SignerCore.mqh at all. The signer's contract is deliberately narrow: give it a method, a URI, a query string, a host, two timestamps, and a payload hash, and it returns a valid Authorization header. Everything AWS-operation-specific lives one layer up, in CS3Client.
Every signed request follows the same five steps regardless of which S3 operation it is: (1) build the canonical request from method, URI, query, headers, and payload hash; (2) hash the canonical request and wrap it into the string-to-sign; (3) derive a signing key scoped to today's date, the region, and the service; (4) HMAC the string-to-sign with that key; (5) assemble the Authorization header from the credential scope, signed-headers list, and hex signature.
AWS4SignerCore.mqh: Canonical Requests and the Manual HMAC Chain
This file is the actual signer, and it starts with three fixed constants SigV4 defines outright rather than derives from the request. AWS4_EMPTY_PAYLOAD_HASH is the odd one out here: CryptEncode(CRYPT_HASH_SHA256,...) refuses a zero-length input array. Call it on an empty buffer and it does not return a valid empty-string hash - it fails outright with GetLastError() reporting 4006. Since every GET request (no body to hash) needs the SHA-256 of an empty payload, and that value never changes regardless of the request, the constant is signed directly rather than asking the platform to compute a hash of nothing at runtime.
//+------------------------------------------------------------------+ //| Defines | //| AWS Signature Version 4 uses a fixed algorithm token and a fixed | //| request-type terminator string in the signing-key derivation | //| chain and in the credential scope; both are constants defined by | //| the spec itself, never derived from the request, so they are | //| pinned here rather than rebuilt as literals in every method that | //| uses them. AWS4_EMPTY_PAYLOAD_HASH is the fixed SHA-256 hash of | //| a zero-length payload, used by every bodyless request (GET, | //| HEAD, ListObjectsV2): CryptEncode(CRYPT_HASH_SHA256, ...) | //| rejects a zero-length input array outright with | //| GetLastError()==4006, so the well-known constant is used | //| directly instead of hashing an empty buffer at runtime. | //+------------------------------------------------------------------+ #define AWS4_ALGORITHM "AWS4-HMAC-SHA256" // fixed algorithm id AWS expects #define AWS4_TERMINATOR "aws4_request" // fixed scope terminator segment #define AWS4_EMPTY_PAYLOAD_HASH \ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // SHA-256("")
StringToBytesUtf8() is the one helper every hashing and HMAC call in this file routes through, and it exists to work around a second, unrelated gotcha. StringToCharArray() always appends a terminating zero byte as the last array element. Left in place, that extra byte becomes part of whatever gets hashed or HMACed - and since SigV4 signatures are exact, a payload that is one byte longer than it should be produces a signature AWS rejects with SignatureDoesNotMatch. A single trimming check right after the conversion removes that trailing byte before it ever reaches a cryptographic routine.
//+------------------------------------------------------------------+ //| StringToBytesUtf8 | //| Converts a string to a raw UTF-8 byte array for use as | //| HMAC/SHA-256 input. StringToCharArray(), called with count=-1 as | //| it is here, appends a terminating zero as the last array | //| element; left in place, that extra zero byte becomes part of the | //| hashed input and produces a signature AWS rejects with | //| SignatureDoesNotMatch, so it is trimmed here before the array is | //| handed to any cryptographic routine. | //+------------------------------------------------------------------+ void StringToBytesUtf8(const string text,uchar &out[]) { int len=StringToCharArray(text,out,0,-1,CP_UTF8); if(len>0 && out[len-1]==0) // drop the trailing terminator ArrayResize(out,len-1); // byte StringToCharArray appended }
CAWS4Signer itself is declared once, with a single header box for the whole class - member groups inside get plain block comments rather than their own boxes, since the class declaration is a listing, not a set of implementations. The private section holds the low-level primitives (hashing, HMAC, percent-encoding); the public section is the actual signing interface CS3Client calls into.
//+------------------------------------------------------------------+ //| CAWS4Signer | //| Native AWS Signature Version 4 request signer. Builds the | //| SHA-256 canonical request, derives the scoped signing key | //| through the kDate / kRegion / kService / kSigning HMAC chain, | //| and produces the final Authorization header value - all from | //| CryptEncode() and a hand-rolled HMAC, with no external DLL or | //| third-party library. | //+------------------------------------------------------------------+ class CAWS4Signer { private: //--- credential and signing-scope state, set once via Init() string m_access_key; string m_secret_key; string m_region; string m_service; //--- low-level hashing, HMAC, and percent-encoding primitives void Sha256Bytes(const uchar &data[],uchar &out[]); void HmacSha256(const uchar &key[],const uchar &msg[],uchar &out[]); string HexEncode(const uchar &data[]); string UrlEncode(const string text,const bool keep_slash); public: //--- setup and the public canonical-request / signing interface void Init(const string access_key,const string secret_key, const string region,const string service); string Sha256Hex(const uchar &data[]); void DeriveSigningKey(const string date_stamp,uchar &signing_key[]); string BuildCanonicalRequest(const string method,const string canonical_uri, const string canonical_query,const string host, const string amz_date,const string payload_hash, string &signed_headers); string BuildStringToSign(const string amz_date,const string date_stamp, const string canonical_request); string SignAndBuildAuthHeader(const string method,const string canonical_uri, const string canonical_query,const string host, const string amz_date,const string date_stamp, const string payload_hash); string EncodeUri(const string text,const bool keep_slash){ return UrlEncode(text,keep_slash); } };
Init() just stores the four values every subsequent call needs - access key, secret key, region, and service code. Nothing here is derived or validated; that happens later, per request.
//+------------------------------------------------------------------+ //| CAWS4Signer::Init | //| Stores the caller's IAM access key, secret key, target region | //| (e.g. "us-east-1"), and service code ("s3") for every canonical | //| request and signature this instance builds afterward. | //+------------------------------------------------------------------+ void CAWS4Signer::Init(const string access_key,const string secret_key, const string region,const string service) { m_access_key=access_key; // IAM access key id, sent in cleartext m_secret_key=secret_key; // IAM secret key, never sent over the wire m_region=region; // e.g. "us-east-1" m_service=service; // fixed to "s3" for this client }
Sha256Bytes() is a thin wrapper over CryptEncode(), and its own shape explains why the empty-payload constant above is necessary: CryptEncode()'s key parameter is only meaningful for the CRYPT_* cipher methods, so a plain hash call passes an empty key array - and it is exactly that combination, empty key plus empty data, that the platform refuses.
//+------------------------------------------------------------------+ //| CAWS4Signer::Sha256Bytes | //| Thin wrapper over the platform's native CryptEncode(CRYPT_HASH_ | //| SHA256, ...) call. An empty key array is passed because this is | //| a plain hash, not an HMAC - CryptEncode's key parameter is only | //| meaningful for the CRYPT_* cipher methods, not the hash methods. | //+------------------------------------------------------------------+ void CAWS4Signer::Sha256Bytes(const uchar &data[],uchar &out[]) { uchar empty_key[]; // hash methods ignore the key argument int written=CryptEncode(CRYPT_HASH_SHA256,data,empty_key,out); if(written<=0) PrintFormat("CAWS4Signer::Sha256Bytes - CryptEncode failed, GetLastError=%d", GetLastError()); }
Sha256Bytes() is also the file's weakest point for error handling: it is a void function, so a CryptEncode() failure is only ever printed, never signaled back to the caller through a return value. Sha256Hex() calls it and hex-encodes whatever ended up in the output array - on failure, that is an empty array, and HexEncode() of an empty array is an empty string, so a caller that does not read the log has no way to distinguish a genuine hash from a silently failed one purely from the return value.
//+------------------------------------------------------------------+ //| CAWS4Signer::Sha256Hex | //| Hashes a byte buffer and returns the digest as a lowercase | //| 64-character hex string, the exact form AWS expects wherever a | //| SHA-256 hash appears inside a canonical request or | //| string-to-sign. | //+------------------------------------------------------------------+ string CAWS4Signer::Sha256Hex(const uchar &data[]) { uchar digest[]; Sha256Bytes(data,digest); return HexEncode(digest); }
HexEncode() is deliberately the simplest function in the file - a byte-by-byte loop through StringFormat("%02x",...) - but it is used in two different contexts that are easy to conflate while reading the rest of the class: it renders a payload hash for the x-amz-content-sha256 header, and separately it renders the final HMAC signature for the Authorization header's Signature= field. Both need the same lowercase, unseparated hex form, which is why one small function serves both call sites rather than each building its own string.
//+------------------------------------------------------------------+ //| CAWS4Signer::HexEncode | //| Renders a raw byte array as a lowercase hex string, two | //| characters per byte, with no separators - the encoding SigV4 | //| uses for both payload hashes and the final HMAC signature. | //+------------------------------------------------------------------+ string CAWS4Signer::HexEncode(const uchar &data[]) { string out=""; int n=ArraySize(data); for(int i=0;i<n;i++) out+=StringFormat("%02x",data[i]); return out; }
HmacSha256() is the piece MQL5 does not give you: CryptEncode() exposes the raw SHA-256 primitive, but there is no built-in HMAC method, so RFC 2104 is implemented directly. A key longer than the 64-byte SHA-256 block size gets hashed down first; a shorter one is zero-padded. The ipad/opad XOR constants and the two-pass H((K'^opad) || H((K'^ipad) || msg)) structure come straight from the RFC, not from any AWS-specific detail - this function would work for HMAC-SHA256 against any protocol, not just SigV4.
//+------------------------------------------------------------------+ //| CAWS4Signer::HmacSha256 | //| Computes HMAC-SHA256(key, msg) by hand, since CryptEncode() | //| exposes the raw hash primitive but MQL5 has no built-in HMAC | //| method. Follows RFC 2104 directly: pad or hash the key down to | //| the 64-byte SHA-256 block size, XOR it against the ipad/opad | //| constants, and run two hash passes - H((K'^opad) || H((K'^ipad) | //| || msg)). | //+------------------------------------------------------------------+ void CAWS4Signer::HmacSha256(const uchar &key[],const uchar &msg[],uchar &out[]) { uchar k[64]; ArrayInitialize(k,0); // zero-pad short keys to the block size int key_len=ArraySize(key); if(key_len>64) { //--- RFC 2104: an oversized key is hashed down to 32 bytes first uchar hashed_key[]; Sha256Bytes(key,hashed_key); ArrayCopy(k,hashed_key,0,0,ArraySize(hashed_key)); } else ArrayCopy(k,key,0,0,key_len); uchar ipad[64],opad[64]; for(int i=0;i<64;i++) { ipad[i]=(uchar)(k[i]^0x36); // inner pad constant, per RFC 2104 opad[i]=(uchar)(k[i]^0x5C); // outer pad constant, per RFC 2104 } //--- inner pass: H((K'^ipad) || msg) uchar inner_input[]; ArrayResize(inner_input,64+ArraySize(msg)); ArrayCopy(inner_input,ipad,0,0,64); ArrayCopy(inner_input,msg,64,0,ArraySize(msg)); uchar inner_hash[]; Sha256Bytes(inner_input,inner_hash); //--- outer pass: H((K'^opad) || inner_hash) uchar outer_input[]; ArrayResize(outer_input,64+ArraySize(inner_hash)); ArrayCopy(outer_input,opad,0,0,64); ArrayCopy(outer_input,inner_hash,64,0,ArraySize(inner_hash)); Sha256Bytes(outer_input,out); }
UrlEncode() implements RFC 3986 percent-encoding, and the keep_slash flag is the detail that matters: a canonical URI path keeps its "/" separators untouched, but a query-string key or value must have every "/" escaped to %2F. Getting this wrong - encoding a URI's slashes, or leaving a query value's slashes raw - produces a canonical request that does not match what S3 reconstructs on its end, and AWS rejects the request with SignatureDoesNotMatch: an explicit error, though one that gives no hint the actual cause was slash-encoding specifically.
This implementation also stays within the ASCII range CryptEncode-friendly code deals in - it percent-encodes individual characters, not multi-byte UTF-8 sequences, so an object key containing non-ASCII characters (accented letters, non-Latin scripts) would produce an incorrectly encoded canonical request outright, not just a stylistic mismatch.
//+------------------------------------------------------------------+ //| CAWS4Signer::UrlEncode | //| RFC 3986 percent-encoding for canonical URIs and query strings. | //| Unreserved characters (A-Z a-z 0-9 - _ . ~) pass through | //| untouched; everything else becomes %XX uppercase hex. keep_slash | //| controls whether '/' is preserved (canonical URI path segments) | //| or encoded as %2F (query-string keys and values, per the SigV4 | //| spec). | //+------------------------------------------------------------------+ string CAWS4Signer::UrlEncode(const string text,const bool keep_slash) { string result=""; int len=StringLen(text); for(int i=0;i<len;i++) { ushort ch=StringGetCharacter(text,i); bool is_unreserved= (ch>=65 && ch<=90) || // A-Z (ch>=97 && ch<=122) || // a-z (ch>=48 && ch<=57) || // 0-9 ch=='-' || ch=='_' || ch=='.' || ch=='~'; if(is_unreserved || (keep_slash && ch=='/')) result+=ShortToString(ch); else result+=StringFormat("%%%02X",ch); // uppercase hex, per RFC 3986 } return result; }
DeriveSigningKey() is the four-step chain that scopes the raw secret key down to one exact date, one region, and one service, so a signature computed today can never be replayed against tomorrow's requests or a different AWS service using the same secret. Each step feeds the previous HMAC output back in as the key for the next. The date_stamp parameter here is the bare yyyyMMdd form, not the full x-amz-date timestamp with hours, minutes, and seconds - scoping to the day rather than the second is intentional on AWS's part, since it lets a signing key derived once be reused for every request made within the same UTC day rather than re-derived per call, though this client re-derives it on every SignAndBuildAuthHeader() call for simplicity rather than caching it.
SigV4 Signing-Key Derivation Chain

Fig. 2. Four HMAC passes turn the raw secret key into a signing key scoped to one date, region, and service.
//+------------------------------------------------------------------+ //| CAWS4Signer::DeriveSigningKey | //| Walks the four-step SigV4 key-derivation chain, scoping the raw | //| secret key down to one date, one region, and one service so a | //| leaked signature from today can never be replayed against a | //| future date or a different AWS service: kDate = | //| HMAC("AWS4"+secret, date) -> kRegion = HMAC(kDate, region) -> | //| kService = HMAC(kRegion, "s3") -> kSigning = HMAC(kService, | //| "aws4_request"). | //+------------------------------------------------------------------+ void CAWS4Signer::DeriveSigningKey(const string date_stamp,uchar &signing_key[]) { uchar seed_key[]; StringToBytesUtf8("AWS4"+m_secret_key,seed_key); uchar date_bytes[]; StringToBytesUtf8(date_stamp,date_bytes); uchar k_date[]; HmacSha256(seed_key,date_bytes,k_date); // scope to the date uchar region_bytes[]; StringToBytesUtf8(m_region,region_bytes); uchar k_region[]; HmacSha256(k_date,region_bytes,k_region); // scope to the region uchar service_bytes[]; StringToBytesUtf8(m_service,service_bytes); uchar k_service[]; HmacSha256(k_region,service_bytes,k_service); // scope to the service uchar terminator_bytes[]; StringToBytesUtf8(AWS4_TERMINATOR,terminator_bytes); HmacSha256(k_service,terminator_bytes,signing_key); // final signing key }
BuildCanonicalRequest() assembles the exact five-part string SigV4 defines: method, canonical URI, canonical query string, canonical headers, the signed-headers list, and the payload hash. Only three headers are signed here - host, x-amz-date, and x-amz-content-sha256 - which is the minimum S3 actually requires; every additional signed header is one more thing that has to match byte-for-byte between what gets sent and what gets signed, so this client keeps that surface as small as correctness allows.
//+------------------------------------------------------------------+ //| CAWS4Signer::BuildCanonicalRequest | //| Assembles the exact five-part canonical request string SigV4 | //| defines: HTTP method, canonical URI, canonical query string, | //| canonical headers (each lowercased, colon-joined, | //| newline-terminated, in sorted order), the signed-headers list, | //| and the hex-encoded SHA-256 hash of the payload. Only host, | //| x-amz-date, and x-amz-content-sha256 are signed - the minimum S3 | //| requires. | //+------------------------------------------------------------------+ string CAWS4Signer::BuildCanonicalRequest(const string method,const string canonical_uri, const string canonical_query,const string host, const string amz_date,const string payload_hash, string &signed_headers) { //--- headers must be lowercase, colon-joined, and newline-terminated, //--- listed here in the sorted order SigV4 requires string canonical_headers= "host:"+host+"\n"+ "x-amz-content-sha256:"+payload_hash+"\n"+ "x-amz-date:"+amz_date+"\n"; signed_headers="host;x-amz-content-sha256;x-amz-date"; string canonical_request= method+"\n"+ canonical_uri+"\n"+ canonical_query+"\n"+ canonical_headers+"\n"+ signed_headers+"\n"+ payload_hash; return canonical_request; }
BuildStringToSign() builds the fixed structure that SigV4 signs: the algorithm token, the timestamp, the credential scope, and the SHA-256 of the canonical request. The client can verify only that it builds this structure correctly; it cannot infer anything about how AWS implements verification internally.
//+------------------------------------------------------------------+ //| CAWS4Signer::BuildStringToSign | //| Wraps the canonical request's own SHA-256 hash inside the | //| three-line SigV4 "string to sign": the fixed algorithm token, | //| the request timestamp, the date/region/service credential scope, | //| and finally the canonical request's hash itself - this, not the | //| canonical request directly, is what actually gets HMAC-signed. | //+------------------------------------------------------------------+ string CAWS4Signer::BuildStringToSign(const string amz_date,const string date_stamp, const string canonical_request) { uchar cr_bytes[]; StringToBytesUtf8(canonical_request,cr_bytes); string cr_hash=Sha256Hex(cr_bytes); string credential_scope=date_stamp+"/"+m_region+"/"+m_service+"/"+AWS4_TERMINATOR; string string_to_sign= AWS4_ALGORITHM+"\n"+ amz_date+"\n"+ credential_scope+"\n"+ cr_hash; return string_to_sign; }
SignAndBuildAuthHeader() is the method CS3Client actually calls - it drives every step above end to end and returns the finished Authorization header value, ready to attach to a WebRequest() call.
//+------------------------------------------------------------------+ //| CAWS4Signer::SignAndBuildAuthHeader | //| Drives the full signing pipeline end to end and returns the | //| ready-to-send Authorization header value: builds the canonical | //| request, wraps it into the string-to-sign, derives the scoped | //| signing key, HMACs the string-to-sign with it, and assembles the | //| AWS4-HMAC-SHA256 credential/signed-headers/signature triple. | //+------------------------------------------------------------------+ string CAWS4Signer::SignAndBuildAuthHeader(const string method,const string canonical_uri, const string canonical_query,const string host, const string amz_date,const string date_stamp, const string payload_hash) { string signed_headers; string canonical_request=BuildCanonicalRequest(method,canonical_uri,canonical_query, host,amz_date,payload_hash,signed_headers); string string_to_sign=BuildStringToSign(amz_date,date_stamp,canonical_request); uchar signing_key[]; DeriveSigningKey(date_stamp,signing_key); uchar sts_bytes[]; StringToBytesUtf8(string_to_sign,sts_bytes); uchar signature_bytes[]; HmacSha256(signing_key,sts_bytes,signature_bytes); string signature_hex=HexEncode(signature_bytes); string credential_scope=date_stamp+"/"+m_region+"/"+m_service+"/"+AWS4_TERMINATOR; string authorization= AWS4_ALGORITHM+" "+ "Credential="+m_access_key+"/"+credential_scope+", "+ "SignedHeaders="+signed_headers+", "+ "Signature="+signature_hex; return authorization; }
S3Client.mqh: PutObject, GetObject, and ListObjectsV2 over WebRequest
Where AWS4SignerCore.mqh never issues an HTTP request itself, S3Client.mqh never implements HMAC itself - it builds the pieces a signature needs (host, timestamp, payload hash) and calls CAWS4Signer to do the actual signing, then sends the request with WebRequest(). The class declaration follows the same single-box convention as the signer.
Every method here targets the virtual-hosted-style endpoint form (bucket-name.s3.region.amazonaws.com) rather than the older path-style form (s3.region.amazonaws.com/bucket-name). AWS steers new integrations toward virtual-hosted-style and it is the default this client uses, but general-purpose buckets like the one used throughout this article still accept path-style requests too - only S3 Express One Zone "directory buckets" require virtual-hosted-style exclusively. Building against the recommended form here is a design choice for forward compatibility, not a workaround for something no longer supported.
//+------------------------------------------------------------------+ //| CS3Client | //| Minimal AWS S3 REST client built on CAWS4Signer. Talks to S3's | //| virtual-hosted-style endpoint (bucket.s3.region.amazonaws.com) | //| over WebRequest(), signing every call with a fresh SigV4 | //| Authorization header. Covers exactly the three operations this | //| article's artifact-sync workflow needs: PutObject, GetObject, | //| and ListObjectsV2. | //+------------------------------------------------------------------+ class CS3Client { private: //--- signer instance and bucket/region scope, set once via Init() CAWS4Signer m_signer; string m_bucket; string m_region; //--- endpoint and timestamp helpers shared by every operation string BuildHost(); void BuildAmzTimestamps(string &amz_date,string &date_stamp); public: //--- setup and the three S3 REST operations this client supports void Init(const string access_key,const string secret_key, const string region,const string bucket); bool PutObject(const string object_key,const uchar &data[], const string content_type,int &http_status, string &response_body); bool GetObject(const string object_key,uchar &out_data[], int &http_status,string &error_body); bool ListObjectsV2(const string prefix,string &out_xml, int &http_status); };
//+------------------------------------------------------------------+ //| CS3Client::Init | //| Wires the signer with IAM credentials and scope, and remembers | //| the bucket and region every subsequent request needs to build | //| its virtual-hosted-style host name. | //+------------------------------------------------------------------+ void CS3Client::Init(const string access_key,const string secret_key, const string region,const string bucket) { m_signer.Init(access_key,secret_key,region,"s3"); // "s3" - fixed service code m_region=region; m_bucket=bucket; }
BuildHost() returns the virtual-hosted-style endpoint AWS recommends for new buckets - bucket-name.s3.region.amazonaws.com - rather than the older path-style form.
//+------------------------------------------------------------------+ //| CS3Client::BuildHost | //| Returns the virtual-hosted-style endpoint host name S3 expects, | //| e.g. "my-bucket.s3.us-east-1.amazonaws.com" - AWS's recommended | //| addressing form. General-purpose buckets still accept path-style | //| requests too; this client targets virtual-hosted-style because | //| it is the default AWS steers new integrations toward, not | //| because the alternative has been removed. | //+------------------------------------------------------------------+ string CS3Client::BuildHost() { return m_bucket+".s3."+m_region+".amazonaws.com"; }
BuildAmzTimestamps() is a small function with a detail worth calling out directly: it uses TimeGMT(), not TimeCurrent(). A broker's server time is not necessarily GMT, and SigV4 rejects a request whose x-amz-date header is more than roughly fifteen minutes off from AWS's own clock - signing against server time on a broker with any GMT offset would produce a request rejected for clock skew rather than a bad signature, a distinction AWS actually reports separately (RequestTimeTooSkewed vs. SignatureDoesNotMatch) but which is still worth avoiding outright.
//+------------------------------------------------------------------+ //| CS3Client::BuildAmzTimestamps | //| Produces the two date formats every SigV4 request needs from the | //| same GMT instant: the full x-amz-date header (yyyyMMddTHHmmssZ) | //| and the bare date_stamp (yyyyMMdd) used in the credential scope | //| and the signing-key derivation. Uses TimeGMT(), not | //| TimeCurrent(), since a broker server offset would otherwise sign | //| requests against the wrong timestamp and AWS would reject them | //| for clock skew. | //+------------------------------------------------------------------+ void CS3Client::BuildAmzTimestamps(string &amz_date,string &date_stamp) { MqlDateTime dt; TimeToStruct(TimeGMT(),dt); // GMT, not server time - see header note amz_date=StringFormat("%04d%02d%02dT%02d%02d%02dZ", dt.year,dt.mon,dt.day,dt.hour,dt.min,dt.sec); date_stamp=StringFormat("%04d%02d%02d",dt.year,dt.mon,dt.day); }
PutObject() is the most involved method, because it has to hash the actual payload for both the x-amz-content-sha256 header and the signature - the two have to match exactly, since S3 recomputes the header value on its own end and compares it against what was signed. WebRequest()'s data and result parameters are declared as char[], not uchar[], while the rest of this codebase - the payload itself, CryptEncode(), CharArrayToString() - works in uchar[] throughout. ArrayCopy() bridges that boundary in both directions: put_data[] is a char[] copy of the caller's uchar[] payload going in, and result_bytes[] converts WebRequest()'s char[] response back to uchar[] so CharArrayToString() can decode it. A byte above 127 still round-trips correctly through that conversion, since char and uchar are both single-byte types and the underlying bit pattern survives the cast each way.
//+------------------------------------------------------------------+ //| CS3Client::PutObject | //| Uploads a byte buffer to bucket/object_key with a signed PUT. | //| Hashes the payload once for both the x-amz-content-sha256 header | //| and the signature itself (S3 requires the two to match exactly), | //| then attaches the resulting Authorization header and sends the | //| request. WebRequest() takes char[] buffers, not uchar[], so | //| ArrayCopy() converts the payload and response at that boundary. | //| Returns true only on a 2xx response; the raw status code and | //| response body are always written back for the caller to log. | //+------------------------------------------------------------------+ bool CS3Client::PutObject(const string object_key,const uchar &data[], const string content_type,int &http_status, string &response_body) { string amz_date,date_stamp; BuildAmzTimestamps(amz_date,date_stamp); string host=BuildHost(); string canonical_uri="/"+m_signer.EncodeUri(object_key,true); string canonical_query=""; // PutObject has no query string string payload_hash=m_signer.Sha256Hex(data); // must match the signed hash exactly string authorization=m_signer.SignAndBuildAuthHeader( "PUT",canonical_uri,canonical_query,host,amz_date,date_stamp,payload_hash); string headers= "Host: "+host+"\r\n"+ "x-amz-date: "+amz_date+"\r\n"+ "x-amz-content-sha256: "+payload_hash+"\r\n"+ "Content-Type: "+content_type+"\r\n"+ "Authorization: "+authorization+"\r\n"; string url="https://"+host+canonical_uri; char put_data[],result[]; string result_headers; ArrayCopy(put_data,data); // uchar -> char: WebRequest's data[] param is char[] ResetLastError(); int status=WebRequest("PUT",url,headers,5000,put_data,result,result_headers); http_status=status; uchar result_bytes[]; ArrayCopy(result_bytes,result); // char -> uchar: CharArrayToString() takes uchar[] response_body=CharArrayToString(result_bytes,0,-1,CP_UTF8); if(status==-1) { //--- -1 almost always means the host isn't WebRequest-allow-listed //--- in Tools > Options > Expert Advisors, not a signing error PrintFormat("CS3Client::PutObject - WebRequest failed, GetLastError=%d", GetLastError()); return false; } return(status>=200 && status<300); }
GetObject() is where the empty-payload constant from AWS4SignerCore.mqh actually gets used. A GET has no request body, so there is nothing to hash on the wire - but SigV4 still requires a payload-hash value in the signature, and that value is the fixed SHA-256 of an empty string. Using AWS4_EMPTY_PAYLOAD_HASH directly instead of computing it via CryptEncode() at runtime avoids the empty-array failure described above. WebRequest() writes the downloaded bytes into a char[] buffer here too, so ArrayCopy() converts that into out_data - the uchar[] the caller actually gets back and the type the checksum comparison in the test script expects.
The method also returns an error_body string, populated only when the response is not 2xx: out_data still holds whatever WebRequest() wrote on a failed call, normally S3's XML error body, so decoding it here gives the caller real diagnostic text instead of a bare status code to guess from.
//+------------------------------------------------------------------+ //| CS3Client::GetObject | //| Downloads bucket/object_key with a signed GET and writes the raw | //| response bytes into out_data. A GET carries no request body, so | //| the signed payload hash is the fixed AWS4_EMPTY_PAYLOAD_HASH | //| constant, not a runtime hash of an empty buffer: CryptEncode() | //| rejects a zero-length input array outright (GetLastError()== | //| 4006), so hashing nothing at runtime silently yields an empty | //| string and a signature built against the wrong payload hash - | //| AWS then rejects every GET with a 400. The empty-string hash is | //| a fixed value regardless, so it never needs to be computed at | //| all. WebRequest() writes its response into a char[] buffer, not | //| uchar[], so ArrayCopy() converts it into out_data for the | //| caller. On a non-2xx response, out_data still holds whatever | //| WebRequest() wrote - normally S3's XML error body - so | //| error_body decodes that text for the caller to log rather than | //| leaving a failed GET with no diagnostic content beyond a bare | //| status code. | //+------------------------------------------------------------------+ bool CS3Client::GetObject(const string object_key,uchar &out_data[], int &http_status,string &error_body) { string amz_date,date_stamp; BuildAmzTimestamps(amz_date,date_stamp); string host=BuildHost(); string canonical_uri="/"+m_signer.EncodeUri(object_key,true); string canonical_query=""; string payload_hash=AWS4_EMPTY_PAYLOAD_HASH; // fixed - see AWS4SignerCore.mqh string authorization=m_signer.SignAndBuildAuthHeader( "GET",canonical_uri,canonical_query,host,amz_date,date_stamp,payload_hash); string headers= "Host: "+host+"\r\n"+ "x-amz-date: "+amz_date+"\r\n"+ "x-amz-content-sha256: "+payload_hash+"\r\n"+ "Authorization: "+authorization+"\r\n"; string url="https://"+host+canonical_uri; char no_data[],result[]; string result_headers; ResetLastError(); int status=WebRequest("GET",url,headers,5000,no_data,result,result_headers); http_status=status; ArrayCopy(out_data,result); // char -> uchar: caller/checksum code expects uchar[] if(status==-1) { PrintFormat("CS3Client::GetObject - WebRequest failed, GetLastError=%d", GetLastError()); return false; } bool ok=(status>=200 && status<300); if(!ok) error_body=CharArrayToString(out_data,0,-1,CP_UTF8); // S3's XML error, for logging return ok; }
ListObjectsV2() is the same shape as GetObject() with one added wrinkle: the query string participates in signing exactly like a header does, so its parameters have to be assembled in sorted key order ("list-type" before "prefix", alphabetically) and percent-encoded with keep_slash set to false, since a "/" inside a query value must be escaped to %2F even though the same character is left alone inside a canonical URI path.
//+------------------------------------------------------------------+ //| CS3Client::ListObjectsV2 | //| Lists objects under an optional key prefix using S3's | //| ListObjectsV2 operation (list-type=2). The query string | //| participates in signing just like a header does, so its | //| parameters must be assembled in sorted key order ("list-type" | //| before "prefix") and percent-encoded with '/' escaped, exactly | //| as canonical-URI encoding differs from canonical-query encoding | //| in the SigV4 spec. This is a GET, so it signs with the same | //| fixed AWS4_EMPTY_PAYLOAD_HASH constant GetObject uses, for the | //| same reason, and the same char[]/uchar[] conversion around | //| WebRequest() applies here too. | //+------------------------------------------------------------------+ bool CS3Client::ListObjectsV2(const string prefix,string &out_xml, int &http_status) { string amz_date,date_stamp; BuildAmzTimestamps(amz_date,date_stamp); string host=BuildHost(); string canonical_uri="/"; string canonical_query="list-type=2"; // sorted before "prefix" already if(StringLen(prefix)>0) canonical_query+="&prefix="+m_signer.EncodeUri(prefix,false); // '/' -> %2F here string payload_hash=AWS4_EMPTY_PAYLOAD_HASH; // fixed - see AWS4SignerCore.mqh string authorization=m_signer.SignAndBuildAuthHeader( "GET",canonical_uri,canonical_query,host,amz_date,date_stamp,payload_hash); string headers= "Host: "+host+"\r\n"+ "x-amz-date: "+amz_date+"\r\n"+ "x-amz-content-sha256: "+payload_hash+"\r\n"+ "Authorization: "+authorization+"\r\n"; string url="https://"+host+canonical_uri+"?"+canonical_query; char no_data[],result[]; string result_headers; ResetLastError(); int status=WebRequest("GET",url,headers,5000,no_data,result,result_headers); http_status=status; uchar result_bytes[]; ArrayCopy(result_bytes,result); // char -> uchar: CharArrayToString() takes uchar[] out_xml=CharArrayToString(result_bytes,0,-1,CP_UTF8); if(status==-1) { PrintFormat("CS3Client::ListObjectsV2 - WebRequest failed, GetLastError=%d", GetLastError()); return false; } return(status>=200 && status<300); }
S3ArtifactSync_Script.mq5: Reading, Uploading, and Checksum-Verifying Artifacts
This is a Script, not an Expert Advisor - it runs once when dropped on a chart and exits, with no OnTick() and nothing left resident afterward. That shape fits what it actually does: a one-shot sync of whatever local artifacts currently exist, not a continuous process. An EA wrapping the same CS3Client calls on a timer is a reasonable next step for anyone wanting automatic periodic sync, but it is a deliberately separate concern from proving the signing and transfer logic works correctly in the first place, which is what this script is actually for.
//+------------------------------------------------------------------+ //| Includes | //| AWS4SignerCore.mqh supplies CAWS4Signer (used directly here only | //| for its Sha256Hex checksum helper); S3Client.mqh supplies | //| CS3Client, which wraps the signer around WebRequest() for | //| PutObject/GetObject. | //+------------------------------------------------------------------+ #include <S3ArtifactSync\AWS4SignerCore.mqh> #include <S3ArtifactSync\S3Client.mqh>
The input block covers credentials, bucket/region scope, and the local/remote paths for the two artifacts this script handles. Access and secret keys are entered directly in the Inputs dialog at run time, and no line in this code writes them to a file or prints them to the log - though MetaTrader 5 itself can persist Inputs values in a saved.set file or chart template if the user chooses to save one, which is outside this script's control and worth knowing before saving a template with real credentials filled in.
//+------------------------------------------------------------------+ //| Inputs | //| Credentials and bucket/region scope for the S3 client, plus the | //| local MQL5\Files-relative source paths and destination S3 keys | //| for the two artifacts this script synchronizes: a trained ONNX | //| model and a feature-snapshot CSV. Access/secret keys are entered | //| directly by the user at run time and never written to disk or | //| log. | //+------------------------------------------------------------------+ input string InpAccessKey = ""; // AWS IAM access key id input string InpSecretKey = ""; // AWS IAM secret access key input string InpRegion = "us-east-1"; // AWS region code, e.g. us-east-1 input string InpBucket = ""; // target S3 bucket name input string InpLocalModelFile = "S3ArtifactSync\\model.onnx"; // MQL5\Files-relative path to the model input string InpLocalFeatureFile = "S3ArtifactSync\\features.csv"; // MQL5\Files-relative path to the CSV input string InpModelObjectKey = "models/latest/model.onnx"; // destination S3 key for the model input string InpFeatureObjectKey = "features/latest/features.csv"; // destination S3 key for the CSV
ReadFileBytes() tries FILE_COMMON first, falling back to the terminal-local Files\ folder. This matters in practice because a model exported by a separate training step - running on the same machine but through a different tool, or via MetaTrader5's Python package - often lands in the shared common folder rather than the sandboxed per-terminal one; opening the wrong location either fails outright or silently reads a stale copy left over from an earlier run.
//+------------------------------------------------------------------+ //| ReadFileBytes | //| Loads a file from MQL5\Files (or the shared MQL5\Common\Files | //| location, tried first) into a byte array. Tries FILE_COMMON | //| before the terminal-local path because a model exported by a | //| separate training/export step often lands in the common folder | //| shared across terminals, not the sandboxed per-terminal one - | //| opening the wrong one silently fails or reads a stale copy. | //+------------------------------------------------------------------+ bool ReadFileBytes(const string relative_path,uchar &out[]) { int handle=FileOpen(relative_path,FILE_READ|FILE_BIN|FILE_COMMON); if(handle==INVALID_HANDLE) handle=FileOpen(relative_path,FILE_READ|FILE_BIN); // fall back to local Files\ if(handle==INVALID_HANDLE) { PrintFormat("ReadFileBytes - could not open '%s', GetLastError=%d", relative_path,GetLastError()); return false; } ulong size=FileSize(handle); ArrayResize(out,(int)size); uint read=FileReadArray(handle,out,0,(int)size); FileClose(handle); if((ulong)read!=size) { PrintFormat("ReadFileBytes - short read on '%s': got %d of %d bytes", relative_path,read,(int)size); return false; } return true; }
SyncOneArtifact() is the actual orchestration: read local bytes, PUT them, GET the same key back, and SHA-256 checksum-compare the two. The checksum comparison is not a formality - a PUT that returns HTTP 200 only confirms S3 accepted the request, not that the bytes it stored are identical to what was sent. Comparing hashes on both ends is the only step that actually proves a correct round trip.
//+------------------------------------------------------------------+ //| SyncOneArtifact | //| Runs one full round trip for a single artifact: read the local | //| file, PUT it to S3, GET it back, and compare SHA-256 checksums | //| between the local bytes and what S3 actually stored - a | //| byte-identical round trip is direct proof the signature, | //| encoding, and transfer all worked, stronger than just checking | //| that the HTTP status came back 2xx. Every stage's status code, | //| timing, and (on failure) response body is logged. | //+------------------------------------------------------------------+ bool SyncOneArtifact(CS3Client &client,const string local_path, const string object_key,const string content_type) { uchar local_bytes[]; if(!ReadFileBytes(local_path,local_bytes)) return false; PrintFormat("SyncOneArtifact - read %d bytes from '%s'", ArraySize(local_bytes),local_path); //--- upload uint t0=GetTickCount(); int put_status; string put_body; bool put_ok=client.PutObject(object_key,local_bytes,content_type, put_status,put_body); uint put_ms=GetTickCount()-t0; PrintFormat("SyncOneArtifact - PUT %s: status=%d (%u ms)", object_key,put_status,put_ms); if(!put_ok) { PrintFormat("SyncOneArtifact - PUT failed, response body: %s",put_body); return false; } //--- download the same key back for verification t0=GetTickCount(); uchar downloaded_bytes[]; int get_status; string get_error_body; bool get_ok=client.GetObject(object_key,downloaded_bytes,get_status,get_error_body); uint get_ms=GetTickCount()-t0; PrintFormat("SyncOneArtifact - GET %s: status=%d (%u ms)", object_key,get_status,get_ms); if(!get_ok) { PrintFormat("SyncOneArtifact - GET failed, response body: %s",get_error_body); return false; } //--- checksum comparison - the clearest local proof of a correct round trip CAWS4Signer checksum_signer; string local_hash=checksum_signer.Sha256Hex(local_bytes); string remote_hash=checksum_signer.Sha256Hex(downloaded_bytes); bool match=(local_hash==remote_hash); PrintFormat("SyncOneArtifact - checksum %s: local=%s remote=%s", match ? "MATCH" : "MISMATCH",local_hash,remote_hash); return match; }
//+------------------------------------------------------------------+ //| OnStart | //| Script entry point. Initializes one CS3Client against the | //| configured bucket/region, then runs SyncOneArtifact for the | //| model file and the feature-CSV file in turn, printing a final | //| plain-language pass/fail summary once both round trips complete. | //+------------------------------------------------------------------+ void OnStart() { PrintFormat("S3ArtifactSync: starting sync against bucket '%s' in region '%s'", InpBucket,InpRegion); CS3Client client; client.Init(InpAccessKey,InpSecretKey,InpRegion,InpBucket); bool model_ok=SyncOneArtifact(client,InpLocalModelFile,InpModelObjectKey, "application/octet-stream"); bool features_ok=SyncOneArtifact(client,InpLocalFeatureFile,InpFeatureObjectKey, "text/csv"); if(model_ok && features_ok) Print("S3ArtifactSync: both artifacts uploaded and checksum-verified successfully."); else Print("S3ArtifactSync: at least one artifact failed upload or checksum verification " "- see the log above for the failing stage."); }
Testing
There is no Strategy Tester backtest here - this is a connectivity tool, not a signal generator, so "testing" means running the script once against a real S3 bucket and checking that every stage actually succeeds. The results below come from a real run: a bucket named adesewa-mt5-artifacts in eu-north-1, an IAM user scoped to PutObject/GetObject/ListBucket on that bucket alone, and two real local files staged in MQL5\Files\S3ArtifactSync\.
The IAM policy attached to the test user grants exactly three actions - s3:ListBucket on the bucket resource itself, and s3:PutObject/s3:GetObject scoped to objects under that one bucket's ARN - rather than a broad AmazonS3FullAccess-style policy. This is worth calling out as the actual working configuration, not just a security footnote: a key this narrowly scoped can only ever touch the one bucket it was created for, so even if the access key and secret leaked, the damage they could do is bounded to that bucket's contents.
| Artifact | Local Size | PUT | GET | Checksum |
|---|---|---|---|---|
| model.onnx | 42 bytes | 200 (860 ms) | 200 (187 ms) | MATCH |
| features.csv | 58 bytes | 200 (219 ms) | 200 (172 ms) | MATCH |
Both artifacts uploaded, downloaded, and checksum-matched in this run. The two files are deliberately tiny for this test - 42 and 58 bytes - since the point of this pass is proving the signing and correctness of the pipeline, not exercising performance at model-file scale. The signing and canonicalization logic itself does not change based on payload size, but that is a narrower claim than performance parity: PutObject() and GetObject() both pass a fixed 5000 ms timeout into WebRequest(), and this test never exercised whether that timeout holds up against a multi-megabyte model file on a slow connection - a real, untested risk covered below in Limitations, not something this run can speak to either way.
This run also covers only two of the client's three operations. PutObject() and GetObject() were both exercised against the real bucket; ListObjectsV2() was not called at any point in this test, so nothing here confirms its query-string signing is actually correct against a live bucket - the IAM policy grants s3:ListBucket, but that permission existing is not the same thing as the operation having been exercised. ListObjectsV2() is presented in the S3Client.mqh section as real, compilable code that follows the same signing pattern as the tested operations, not as a result verified by this run.
The full local hashes from that run, for reference: model.onnx checksummed to 41c247b8eb4e4d5a114a34336fa05ab9613f3... and features.csv to 59f5e05119ef9a14d697fb786829204bf81b6..., with the downloaded copy matching on both ends of the round trip.
This is where the empty-payload issue appears in practice. Computing the empty-payload hash at runtime with CryptEncode() fails (GetLastError()==4006) and yields an empty payload_hash string, so the signature is invalid and S3 rejects the GET with status 400. A zero-length array passed to PutObject() would hit the same failure; both test files here are non-empty, so that path was not exercised. GetObject() signs against the fixed AWS4_EMPTY_PAYLOAD_HASH constant instead, and both artifacts returned 200 with a checksum MATCH, as in the table above. ListObjectsV2() uses the same constant but was not called in this run.
Practical takeaway: if a native MQL5 signer built on CryptEncode() needs the hash of an empty payload, test whether a zero-length input array is rejected before relying on it; in this test, CRYPT_HASH_SHA256 rejected it. Where it is rejected, use the algorithm's well-known hash of the empty string as a constant. This was observed only for SHA-256 and only through CryptEncode(); other hash methods may behave differently.
Limitations and Practical Considerations
Clock skew fails silently as a signature error, though AWS does distinguish it internally. SigV4 rejects any request whose x-amz-date is more than roughly fifteen minutes off from AWS's own clock, and while AWS's API does return a distinct RequestTimeTooSkewed error code separate from SignatureDoesNotMatch, that distinction is only useful if the response body is actually captured and inspected - a caller checking only the HTTP status code sees the same generic failure either way. BuildAmzTimestamps() uses TimeGMT() specifically to avoid this on brokers whose server time is not GMT, but a VPS with an incorrect system clock would still trigger the same failure, and unless the error body is read, there is nothing pointing directly at the clock rather than the signature logic.
PutObject() is exposed to the same empty-payload failure if it is given a zero-length array. Sha256Hex(data) in PutObject() calls CryptEncode() on whatever byte array ReadFileBytes() returns; if that array happens to be zero-length (an empty local file), the same CryptEncode zero-length-array failure documented in AWS4SignerCore.mqh would apply to the PUT path too. Both artifacts tested here are non-empty, so this was not exercised, and PutObject() has no explicit guard against it - a caller uploading a genuinely empty file would need to test that case separately.
The char[]/uchar[] conversion at each WebRequest() call is a byte-for-byte cast, exercised only against the two real artifacts in this test. WebRequest()'s own data and result parameters are char[], while the rest of this client works in uchar[], so PutObject(), GetObject(), and ListObjectsV2() each run the buffer through ArrayCopy() to cross that boundary. A single byte round-trips correctly through a char/uchar cast regardless of its value, since both are 8-bit types, but that was only confirmed here against the two real files transferred in the test run - a model.onnx and a features.csv - not against a byte array built specifically to cover the full 0-255 range.
WebRequest() has no native retry or backoff. A transient network failure, an S3 throttling response, or a momentary DNS hiccup all surface the same way - a single failed call, with the burden of any retry logic left entirely to the caller. SyncOneArtifact() here runs once and reports pass/fail; a production sync job would want to wrap each PutObject()/GetObject() call in its own retry loop with exponential backoff before treating a single failure as final.
Credentials sit in cleartext in the Inputs dialog and in memory for the life of the script. This is standard for any WebRequest()-based integration in MQL5 - there is no native secrets vault - but it is worth stating plainly: an access key with PutObject/GetObject/ListBucket scoped to exactly one bucket, as used throughout this article, limits the blast radius of a leaked key far more than reusing a broadly-scoped key across multiple tools would.
Canonical headers and canonical query construction are both special-cased, not general. BuildCanonicalRequest() signs a fixed three-header set with no whitespace-trimming/collapsing logic, which SigV4 requires for header values in the general case - fine for the fixed values this client sends, but not a drop-in for a caller needing to sign additional headers. ListObjectsV2()'s query-string order ("list-type" before "prefix") is hardcoded for those two exact parameters rather than built from a general sort-and-encode routine; adding a third query parameter would mean manually re-checking alphabetical order rather than it being handled automatically.
ListObjectsV2() returns raw XML, not a parsed structure. S3's list response is XML, and this client hands the caller the raw response body rather than walking it into an array of object keys - a real artifact-management workflow that needs to enumerate what is already in a bucket (to decide whether to skip an unchanged upload, for instance) would need to add XML parsing on top of what is here.
No multipart upload support. PutObject() sends the entire payload in a single WebRequest() call. S3 supports (and for large objects, recommends) multipart upload, splitting a file into parts uploaded independently and then assembled server-side; a model file large enough to approach WebRequest()'s own timeout or MetaTrader 5's request-size handling would need multipart logic added on top of this client, which single-request PutObject() does not provide.
The service code is hardcoded to "s3". CAWS4Signer itself is general-purpose SigV4 - nothing in it is S3-specific - but CS3Client passes "s3" into Init() unconditionally. Reusing CAWS4Signer against a different AWS service (SQS, or a Lambda function URL, for instance) is possible without modifying the signer at all, but would mean writing a new client class alongside CS3Client rather than a bucket/region change to this one.
Region is fixed per CS3Client instance, and must match the bucket exactly. There is no region auto-discovery here - InpRegion has to be the literal region the bucket was actually created in, byte-for-byte (eu-north-1, not EU-North-1 or eu-north1), since the region string feeds directly into both the canonical host name and the credential scope used in signing. The bucket.s3.<region>.amazonaws.com host name generally still resolves even with the wrong region in it, so a mismatch does not typically surface as a DNS failure; S3 instead returns an explicit 400 AuthorizationHeaderMalformed or a 301 PermanentRedirect, both of which name the correct region in the response - useful information this client does not currently surface, since neither GetObject() nor PutObject() parses the error body for a region hint.
No support for temporary AWS credentials. CAWS4Signer signs with a long-lived IAM access key and secret key only - there is no x-amz-security-token header and no session-token parameter anywhere in Init() or the signing chain. This rules out STS AssumeRole, IAM roles, and any credential-rotation setup that issues temporary credentials, which is how a meaningful share of production AWS access is actually configured; using this client means provisioning a long-lived key specifically for it, the way this article's test IAM user was set up.
A bucket name containing dots breaks virtual-hosted-style over HTTPS. BuildHost() builds bucket-name.s3.region.amazonaws.com unconditionally. For a bucket name containing a dot (my.bucket, say), the resulting host does not match AWS's wildcard SSL certificate for virtual-hosted-style addressing, and the HTTPS handshake itself can fail before any request-signing logic even runs. This client has no fallback to path-style addressing for that case - it assumes a dot-free bucket name throughout, which the bucket used in this article's testing happens to satisfy.
Conclusion
SigV4 is not a complicated algorithm once it is broken into its five fixed steps, but it is an unforgiving one - a single mismatched header, an unescaped slash in the wrong place, or a payload hash computed against a different set of bytes than what actually got sent, and AWS returns a generic signature-rejection response with no indication of which of those went wrong. Building it natively in MQL5 meant working entirely from CryptEncode() and WebRequest(), with no existing library to lean on and no HMAC primitive provided - RFC 2104's two-pass HMAC construction had to be implemented directly, XOR padding included.
Key finding: in this test, CryptEncode(CRYPT_HASH_SHA256,...) rejected a zero-length input array (GetLastError()==4006). For bodyless requests, use the known SHA-256 hash of the empty string as a constant instead of hashing an empty array. This behavior was observed only for SHA-256 and may differ for other hash methods.
The canonical-request format itself is worth remembering independent of S3: header names inside it must be lowercased, and their values joined with a single colon and no extra whitespace, regardless of how they are cased in the actual HTTP request that gets sent - S3 lowercases and reformats headers on its own end before comparing, so a canonical request built with the original header casing would produce a hash that never matches what AWS computes for verification, and the resulting signature would fail with no indication that casing was the actual cause.
What this run actually establishes: PutObject() and GetObject() both succeed against a real bucket, and the byte-identical checksum match confirms the signing and transfer logic is correct for small files. It does not establish behavior at model-file scale, under network failure, against a bucket with a dotted name (which runs into the same TLS wildcard-certificate issue any virtual-hosted-style client hits), or using temporary STS credentials rather than a long-lived IAM key - each of those is a real, separate thing to verify before relying on this client for a specific production workflow, not a natural extension of what was tested here. The three files below are a verified starting point for pushing a trained ONNX model or a feature export into S3, not a finished, general-purpose artifact-sync solution.
| File | Type | Description |
|---|---|---|
| AWS4SignerCore.mqh | Include | Native AWS Signature Version 4 signer: canonical request builder, hand-rolled HMAC-SHA256, and the kDate/kRegion/kService/kSigning key-derivation chain. |
| S3Client.mqh | Include | PutObject, GetObject, and ListObjectsV2 wrappers over WebRequest(), using CAWS4Signer for every request's Authorization header. |
| S3ArtifactSync_Script.mq5 | Script | Reads a local model and feature CSV, uploads both to S3, downloads them back, and SHA-256 checksum-verifies the round trip. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.