AWS S3 Raw Authentication written in C#

AWS SDK for C# is actually a very good one and you should use it if you can. Unfortunately in my case it had just too many dependencies and due to attempt to cover all the AWS services (which it should absolutely do!) it’s a very bloated, very hard to read codebase. I needed to perform a single AWS request to S3, very specific REST one, and I don’t want to bring all these packages.

AWS documentation on this topic is excellent, and after many many days of work I could write a working request authenticator in C#. Pasting it here:

  1using System;
  2using System.Collections.Generic;
  3using System.Collections.Specialized;
  4using System.Diagnostics;
  5using System.IO;
  6using System.Linq;
  7using System.Net.Http;
  8using System.Net.Http.Headers;
  9using System.Security.Cryptography;
 10using System.Text;
 11using System.Threading;
 12using System.Threading.Tasks;
 13using System.Web;
 14
 15namespace Storage.IO.Files.Impl.Amazon
 16{
 17   /// <summary>
 18   /// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html
 19   /// 
 20   /// explore python version: https://github.com/tedder/requests-aws4auth
 21   /// </summary>
 22   class S3AuthHandler : DelegatingHandler
 23   {
 24      private readonly string _accessKeyId;
 25      private readonly string _secretAccessKey;
 26      private readonly string _region;
 27      private readonly string _service;
 28      private static readonly string EmptySha256 = new byte[0].SHA256().ToHexString();
 29
 30      public S3AuthHandler(string accessKeyId, string secretAccessKey, string region, string service = "s3") : base(new HttpClientHandler())
 31      {
 32         _accessKeyId = accessKeyId;
 33         _secretAccessKey = secretAccessKey;
 34         _region = region;
 35         _service = service;
 36      }
 37
 38      protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
 39      {
 40         await SignAsync(request);
 41
 42         return await base.SendAsync(request, cancellationToken);
 43      }
 44
 45      protected async Task<string> SignAsync(HttpRequestMessage request, DateTimeOffset? signDate = null)
 46      {
 47         // a very helpful article on S3 auth: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
 48
 49         DateTimeOffset dateToUse = signDate ?? DateTimeOffset.UtcNow;
 50         string nowDate = dateToUse.ToString("yyyyMMdd");
 51         string amzNowDate = GetAmzDate(dateToUse);
 52
 53         request.Headers.Add("x-amz-date", amzNowDate);
 54
 55         // 1. Create a canonical request
 56
 57         /*
 58          * <HTTPMethod>\n
 59          * <CanonicalURI>\n
 60          * <CanonicalQueryString>\n
 61          * <CanonicalHeaders>\n
 62          * <SignedHeaders>\n
 63          * <HashedPayload>
 64          */
 65
 66         string payloadHash = await AddPayloadHashHeader(request);
 67
 68         string canonicalRequest = request.Method + "\n" +
 69            GetCanonicalUri(request) + "\n" +  // CanonicalURI
 70            GetCanonicalQueryString(request) + "\n" +
 71            GetCanonicalHeaders(request, out string signedHeaders) + "\n" +   // ends up with two newlines which is expected
 72            signedHeaders + "\n" +
 73            payloadHash;
 74
 75
 76         // 2. Create a string to sign
 77
 78         // step by step instructions: https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
 79
 80         /*
 81          * StringToSign =
 82          *    Algorithm + \n +
 83          *    RequestDateTime + \n +
 84          *    CredentialScope + \n +
 85          *    HashedCanonicalRequest
 86          */
 87
 88         string stringToSign = "AWS4-HMAC-SHA256\n" +
 89               amzNowDate + "\n" +
 90               nowDate + "/" + _region + "/s3/aws4_request\n" +
 91               canonicalRequest.SHA256();
 92
 93
 94         // 3. Calculate Signature
 95
 96         /*
 97          * DateKey              = HMAC-SHA256("AWS4"+"<SecretAccessKey>", "<YYYYMMDD>")
 98          * DateRegionKey        = HMAC-SHA256(<DateKey>, "<aws-region>")
 99          * DateRegionServiceKey = HMAC-SHA256(<DateRegionKey>, "<aws-service>")
100          * SigningKey           = HMAC-SHA256(<DateRegionServiceKey>, "aws4_request")
101          */
102
103         byte[] kSecret = Encoding.UTF8.GetBytes(("AWS4" + _secretAccessKey).ToCharArray());
104         byte[] kDate = HmacSha256(nowDate, kSecret);
105         byte[] kRegion = HmacSha256(_region, kDate);
106         byte[] kService = HmacSha256(_service, kRegion);
107         byte[] kSigning = HmacSha256("aws4_request", kService);
108
109         // final signature
110         byte[] signatureRaw = HmacSha256(stringToSign, kSigning);
111         string signature = signatureRaw.ToHexString();
112
113         string auth = $"Credential={_accessKeyId}/{nowDate}/{_region}/s3/aws4_request,SignedHeaders={signedHeaders},Signature={signature}";
114         request.Headers.Authorization = new AuthenticationHeaderValue("AWS4-HMAC-SHA256", auth);
115
116         return signature;
117      }
118
119      private static string GetAmzDate(DateTimeOffset date)
120      {
121         return date.ToString("yyyyMMddTHHmmssZ");
122      }
123
124      private string GetCanonicalUri(HttpRequestMessage request)
125      {
126         string path = request.RequestUri.AbsolutePath.TrimStart('/');
127
128         return "/" + path.UrlEncode();
129      }
130
131      private string GetCanonicalQueryString(HttpRequestMessage request)
132      {
133         /**
134          * CanonicalQueryString specifies the URI-encoded query string parameters. You URI-encode name and values individually. You must also sort the parameters in the canonical query string alphabetically by key name. The sorting occurs after encoding.
135          */
136
137
138         NameValueCollection values = HttpUtility.ParseQueryString(request.RequestUri.Query);
139         var sb = new StringBuilder();
140
141         foreach(string key in values.AllKeys.OrderBy(k => k))
142         {
143            if(sb.Length > 0)
144            {
145               sb.Append('&');
146            }
147
148            string value = HttpUtility.UrlEncode(values[key]);
149
150            if(key == null)
151            {
152               sb
153                  .Append(value)
154                  .Append("=");
155            }
156            else
157            {
158               sb
159                  .Append(HttpUtility.UrlEncode(key.ToLower()))
160                  .Append("=")
161                  .Append(value);
162
163            }
164         }
165
166         return sb.ToString();
167      }
168
169      private string GetCanonicalHeaders(HttpRequestMessage request, out string signedHeaders)
170      {
171         // List of request headers with their values.
172         // Individual header name and value pairs are separated by the newline character ("\n").
173         // Header names must be in lowercase. You must sort the header names alphabetically to construct the string.
174
175         // Note that I add some headers manually, but preserve sorting order in the actual code.
176
177         var headers = from kvp in request.Headers
178                       where kvp.Key.StartsWith("x-amz-", StringComparison.OrdinalIgnoreCase)
179                       orderby kvp.Key
180                       select new { Key = kvp.Key.ToLowerInvariant(), kvp.Value };
181
182         var sb = new StringBuilder();
183         var signedHeadersList = new List<string>();
184
185         // The CanonicalHeaders list must include the following:
186         // - HTTP host header.
187         // - If the Content-Type header is present in the request, you must add it to the CanonicalHeaders list.
188         // - Any x-amz-* headers that you plan to include in your request must also be added. For example, if you are using temporary security credentials, you need to include x-amz-security-token in your request. You must add this header in the list of CanonicalHeaders.
189
190         if(request.Headers.Contains("date"))
191         {
192            sb.Append("date:").Append(request.Headers.GetValues("date").First()).Append("\n");
193            signedHeadersList.Add("date");
194         }
195
196         sb.Append("host:").Append(request.RequestUri.Host).Append("\n");
197         signedHeadersList.Add("host");
198
199         string contentType = request.Content?.Headers.ContentType?.ToString();
200         if(contentType != null)
201         {
202            sb.Append("content-type:").Append(contentType).Append("\n");
203            signedHeadersList.Add("content-type");
204         }
205
206         if(request.Headers.Contains("range"))
207         {
208            sb.Append("range:").Append(request.Headers.GetValues("range").First()).Append("\n");
209            signedHeadersList.Add("range");
210         }
211
212         // Create the string in the right format; this is what makes the headers "canonicalized" --
213         //   it means put in a standard format. http://en.wikipedia.org/wiki/Canonicalization
214         foreach(var kvp in headers)
215         {
216            sb.Append(kvp.Key).Append(":");
217            signedHeadersList.Add(kvp.Key);
218
219            foreach(string hv in kvp.Value)
220            {
221               sb.Append(hv);
222            }
223
224            sb.Append("\n");
225         }
226
227         signedHeaders = string.Join(";", signedHeadersList);
228
229         return sb.ToString();
230      }
231
232      /// <summary>
233      /// Hex(SHA256Hash(<payload>))
234      /// </summary>
235      /// <param name="request"></param>
236      /// <returns></returns>
237      private async Task<string> AddPayloadHashHeader(HttpRequestMessage request)
238      {
239         string hash;
240
241         if(request.Content != null)
242         {
243            byte[] content = await request.Content.ReadAsByteArrayAsync();
244            hash = content.SHA256().ToHexString();
245         }
246         else
247         {
248            hash = EmptySha256;
249         }
250
251         request.Headers.Add("x-amz-content-sha256", hash);
252
253         return hash;
254      }
255
256      private static byte[] HmacSha256(string data, byte[] key)
257      {
258         var alg = KeyedHashAlgorithm.Create("HmacSHA256");
259         alg.Key = key;
260         return alg.ComputeHash(Encoding.UTF8.GetBytes(data));
261      }
262   }
263}

Have feedback or questions? Feel free to email me.