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.