• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

loresoft / HashGate / 24360421502

13 Apr 2026 06:38PM UTC coverage: 72.756% (+0.2%) from 72.581%
24360421502

push

github

pwelter34
fix tests

50 of 92 branches covered (54.35%)

Branch coverage included in aggregate %.

177 of 220 relevant lines covered (80.45%)

5.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.57
/src/HashGate.HttpClient/HttpRequestMessageExtensions.cs
1
using System.Net.Http;
2
using System.Security.Cryptography;
3

4
namespace HashGate.HttpClient;
5

6
/// <summary>
7
/// Provides extension methods for <see cref="HttpRequestMessage"/> to add HMAC authentication headers.
8
/// These methods enable automatic signing of HTTP requests using HMAC-SHA256 authentication.
9
/// </summary>
10
public static class HttpRequestMessageExtensions
11
{
12
    /// <summary>
13
    /// Adds HMAC authentication headers to an HTTP request message using the specified client credentials and signed headers.
14
    /// This method automatically generates the required authentication headers including timestamp, content hash, and authorization signature.
15
    /// </summary>
16
    /// <param name="request">The HTTP request message to add HMAC authentication headers to.</param>
17
    /// <param name="client">The client identifier (access key ID) used for authentication.</param>
18
    /// <param name="secret">The secret key used for HMAC-SHA256 signature generation.</param>
19
    /// <param name="signedHeaders">
20
    /// An optional list of header names to include in the signature calculation.
21
    /// If <c>null</c>, the default signed headers (host, x-timestamp, x-content-sha256) will be used.
22
    /// If provided, the default headers will be merged with the specified headers.
23
    /// </param>
24
    /// <returns>A task that represents the asynchronous operation of adding HMAC authentication headers.</returns>
25
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> is <c>null</c>.</exception>
26
    /// <exception cref="ArgumentException">Thrown when <paramref name="client"/> or <paramref name="secret"/> is <c>null</c>, empty, or whitespace.</exception>
27
    /// <remarks>
28
    /// <para>
29
    /// This method performs the following operations:
30
    /// </para>
31
    /// <list type="number">
32
    /// <item><description>Adds an x-timestamp header with the current Unix timestamp</description></item>
33
    /// <item><description>Computes and adds an x-content-sha256 header with the SHA256 hash of the request body</description></item>
34
    /// <item><description>Creates a canonical string representation of the request</description></item>
35
    /// <item><description>Generates an HMAC-SHA256 signature of the canonical string</description></item>
36
    /// <item><description>Adds an Authorization header with the HMAC authentication information</description></item>
37
    /// </list>
38
    /// <para>
39
    /// The method ensures that required headers are always included in the signature, even if not explicitly specified in the signedHeaders parameter.
40
    /// </para>
41
    /// </remarks>
42
    /// <example>
43
    /// <code>
44
    /// var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data");
45
    /// await request.AddHmacAuthentication("my-client-id", "my-secret-key");
46
    ///
47
    /// // Add custom signed headers
48
    /// await request.AddHmacAuthentication(
49
    ///     "my-client-id",
50
    ///     "my-secret-key",
51
    ///     ["host", "x-timestamp", "x-content-sha256", "content-type"]
52
    /// );
53
    /// </code>
54
    /// </example>
55
    public static async Task AddHmacAuthentication(
56
        this HttpRequestMessage request,
57
        string client,
58
        string secret,
59
        IReadOnlyList<string>? signedHeaders = null)
60
    {
61
        if (request is null)
3!
62
            throw new ArgumentNullException(nameof(request));
×
63

64
        if (client is null)
3!
65
            throw new ArgumentNullException(nameof(client));
×
66

67
        if (string.IsNullOrWhiteSpace(client))
3!
68
            throw new ArgumentException("Client cannot be empty or whitespace.", nameof(client));
×
69

70
        if (secret is null)
3!
71
            throw new ArgumentNullException(nameof(secret));
×
72

73
        if (string.IsNullOrWhiteSpace(secret))
3!
74
            throw new ArgumentException("Secret cannot be empty or whitespace.", nameof(secret));
×
75

76
        // ensure required headers are present, dedup and sort case-insensitively
77
        var headerSet = new SortedSet<string>(HmacAuthenticationShared.DefaultSignedHeaders, StringComparer.OrdinalIgnoreCase);
3✔
78
        if (signedHeaders != null)
3✔
79
            headerSet.UnionWith(signedHeaders);
1✔
80

81
        signedHeaders = [.. headerSet];
3✔
82

83
        // add timestamp header
84
        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
3✔
85
        request.Headers.Add(HmacAuthenticationShared.TimeStampHeaderName, timestamp.ToString());
3✔
86

87
        // compute content hash
88
        var contentHash = await GenerateContentHash(request);
3✔
89
        request.Headers.Add(HmacAuthenticationShared.ContentHashHeaderName, contentHash);
3✔
90

91
        // generate nonce
92
        request.Headers.Add(HmacAuthenticationShared.NonceHeaderName, Guid.NewGuid().ToString("N"));
3✔
93

94
        // get header values
95
        var headerValues = GetHeaderValues(request, signedHeaders);
3✔
96

97
        // create string to sign
98
        var stringToSign = HmacAuthenticationShared.CreateStringToSign(
3!
99
            method: request.Method.Method,
3✔
100
            pathAndQuery: request.RequestUri?.PathAndQuery ?? string.Empty,
3✔
101
            headerValues: headerValues);
3✔
102

103
        // compute signature
104
        var signature = HmacAuthenticationShared.GenerateSignature(stringToSign, secret);
3✔
105

106
        // Build Authorization header
107
        var authorizationHeader = HmacAuthenticationShared.GenerateAuthorizationHeader(
3✔
108
            client: client,
3✔
109
            signedHeaders: signedHeaders,
3✔
110
            signature: signature);
3✔
111

112
        // Add Authorization header to request
113
        request.Headers.Add(HmacAuthenticationShared.AuthorizationHeaderName, authorizationHeader);
3✔
114
    }
3✔
115

116
    /// <summary>
117
    /// Adds HMAC authentication headers to an HTTP request message using the specified authentication options.
118
    /// This is a convenience method that extracts the client credentials and signed headers from the options object.
119
    /// </summary>
120
    /// <param name="request">The HTTP request message to add HMAC authentication headers to.</param>
121
    /// <param name="options">The HMAC authentication options containing client credentials and configuration.</param>
122
    /// <returns>A task that represents the asynchronous operation of adding HMAC authentication headers.</returns>
123
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> or <paramref name="options"/> is <c>null</c>.</exception>
124
    /// <remarks>
125
    /// This method delegates to the main <see cref="AddHmacAuthentication(HttpRequestMessage, string, string, IReadOnlyList{string}?)"/>
126
    /// method using the client, secret, and signed headers from the provided options.
127
    /// </remarks>
128
    /// <example>
129
    /// <code>
130
    /// var options = new HmacAuthenticationOptions
131
    /// {
132
    ///     Client = "my-client-id",
133
    ///     Secret = "my-secret-key",
134
    ///     SignedHeaders = ["host", "x-timestamp", "x-content-sha256"]
135
    /// };
136
    ///
137
    /// var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/users");
138
    /// await request.AddHmacAuthentication(options);
139
    /// </code>
140
    /// </example>
141
    public static Task AddHmacAuthentication(
142
        this HttpRequestMessage request,
143
        HmacAuthenticationOptions options)
144
    {
145
        if (request is null)
1!
146
            throw new ArgumentNullException(nameof(request));
×
147
        if (options is null)
1!
148
            throw new ArgumentNullException(nameof(options));
×
149

150
        return request.AddHmacAuthentication(
1✔
151
            client: options.Client,
1✔
152
            secret: options.Secret,
1✔
153
            signedHeaders: options.SignedHeaders);
1✔
154
    }
155

156
    /// <summary>
157
    /// Generates a Base64-encoded SHA256 hash of the HTTP request content.
158
    /// If the request has no content, returns the hash of an empty string.
159
    /// </summary>
160
    /// <param name="request">The HTTP request message to generate the content hash for.</param>
161
    /// <returns>
162
    /// A task that represents the asynchronous operation. The task result contains a Base64-encoded SHA256 hash of the request content.
163
    /// </returns>
164
    /// <remarks>
165
    /// <para>
166
    /// This method handles the following scenarios:
167
    /// </para>
168
    /// <list type="bullet">
169
    /// <item><description>If the request has no content, returns <see cref="HmacAuthenticationShared.EmptyContentHash"/></description></item>
170
    /// <item><description>If the request has content, reads the content as bytes, computes SHA256 hash, and recreates the content stream</description></item>
171
    /// <item><description>Preserves all original content headers when recreating the content stream</description></item>
172
    /// </list>
173
    /// <para>
174
    /// The method consumes the original content stream and recreates it to ensure the request can still be sent normally.
175
    /// This is necessary because HTTP content streams can typically only be read once.
176
    /// </para>
177
    /// <para>
178
    /// Uses stack allocation for Base64 conversion when possible for improved performance.
179
    /// </para>
180
    /// </remarks>
181
    /// <example>
182
    /// <code>
183
    /// var request = new HttpRequestMessage(HttpMethod.Post, "https://api.example.com/users");
184
    /// request.Content = new StringContent("{\"name\":\"John\"}", Encoding.UTF8, "application/json");
185
    ///
186
    /// var contentHash = await request.GenerateContentHash();
187
    /// // contentHash will contain the Base64-encoded SHA256 hash of the JSON content
188
    /// </code>
189
    /// </example>
190
    public static async Task<string> GenerateContentHash(this HttpRequestMessage request)
191
    {
192
        if (request.Content == null)
8✔
193
            return HmacAuthenticationShared.EmptyContentHash;
3✔
194

195
        var bodyBytes = await request.Content.ReadAsByteArrayAsync();
5✔
196

197
#if NETSTANDARD2_0 || NETFRAMEWORK
198
        using var sha256 = SHA256.Create();
199
        var hashBytes = sha256.ComputeHash(bodyBytes);
200
#else
201
        var hashBytes = SHA256.HashData(bodyBytes);
5✔
202
#endif
203

204
        // consume the content stream, need to recreate it
205
        var originalContent = new ByteArrayContent(bodyBytes);
5✔
206
        foreach (var header in request.Content.Headers)
28✔
207
            originalContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
9✔
208

209
        // Restore content with headers
210
        request.Content = originalContent;
5✔
211

212
#if !NETSTANDARD2_0 && !NETFRAMEWORK
213
        // 32 bytes SHA256 -> 44 chars base64
214
        Span<char> base64 = stackalloc char[44];
5✔
215
        if (Convert.TryToBase64Chars(hashBytes, base64, out int charsWritten))
5!
216
            return new string(base64[..charsWritten]);
5✔
217
#endif
218

219
        // if stackalloc is not large enough (should not happen for SHA256)
220
        return Convert.ToBase64String(hashBytes);
×
221
    }
8✔
222

223
    /// <summary>
224
    /// Retrieves the values of the specified headers from an HTTP request message.
225
    /// Returns an array of header values in the same order as the provided header names.
226
    /// </summary>
227
    /// <param name="request">The HTTP request message to extract header values from.</param>
228
    /// <param name="signedHeaders">The list of header names whose values should be retrieved.</param>
229
    /// <returns>
230
    /// An array of header values corresponding to the signed headers.
231
    /// If a header is not found, an empty string is returned for that position.
232
    /// </returns>
233
    /// <remarks>
234
    /// This method calls <see cref="GetHeaderValue(HttpRequestMessage, string)"/> for each header name
235
    /// and collects the results into an array. The order of values matches the order of header names.
236
    /// </remarks>
237
    private static string[] GetHeaderValues(
238
        HttpRequestMessage request,
239
        IReadOnlyList<string> signedHeaders)
240
    {
241
        var headerValues = new string[signedHeaders.Count];
3✔
242

243
        for (var i = 0; i < signedHeaders.Count; i++)
30✔
244
            headerValues[i] = GetHeaderValue(request, signedHeaders[i]) ?? string.Empty;
12!
245

246
        return headerValues;
3✔
247
    }
248

249
    /// <summary>
250
    /// Retrieves the value of a specific header from an HTTP request message.
251
    /// Handles special cases for standard HTTP headers and searches both request headers and content headers.
252
    /// </summary>
253
    /// <param name="request">The HTTP request message to extract the header value from.</param>
254
    /// <param name="headerName">The name of the header to retrieve (case-insensitive).</param>
255
    /// <returns>
256
    /// The header value as a string, or <c>null</c> if the header is not found.
257
    /// For headers with multiple values, returns a comma-separated string.
258
    /// </returns>
259
    /// <remarks>
260
    /// <para>
261
    /// This method handles the following special cases:
262
    /// </para>
263
    /// <list type="bullet">
264
    /// <item><description><c>host</c> - Returns the authority part of the request URI in lowercase</description></item>
265
    /// <item><description><c>content-type</c> - Returns the content type from content headers</description></item>
266
    /// <item><description><c>content-length</c> - Returns the content length from content headers</description></item>
267
    /// <item><description><c>user-agent</c> - Returns the user agent header as a string</description></item>
268
    /// </list>
269
    /// <para>
270
    /// For other headers, searches first in request headers, then in content headers.
271
    /// Multiple header values are joined with commas as per HTTP specification.
272
    /// </para>
273
    /// </remarks>
274
    private static string? GetHeaderValue(
275
        HttpRequestMessage request,
276
        string headerName)
277
    {
278
        if (headerName.Equals("host", StringComparison.InvariantCultureIgnoreCase))
12✔
279
            return request.RequestUri?.Authority.ToLowerInvariant();
3!
280

281
        if (headerName.Equals("content-type", StringComparison.InvariantCultureIgnoreCase))
9!
282
            return request.Content?.Headers.ContentType?.ToString();
×
283

284
        if (headerName.Equals("content-length", StringComparison.InvariantCultureIgnoreCase))
9!
285
            return request.Content?.Headers.ContentLength?.ToString();
×
286

287
        if (headerName.Equals("user-agent", StringComparison.InvariantCultureIgnoreCase))
9!
288
            return request.Headers.UserAgent.ToString();
×
289

290
        if (request.Headers.TryGetValues(headerName, out var values))
9!
291
            return values != null ? string.Join(",", values) : null;
9!
292

293
        if (request.Content != null && request.Content.Headers.TryGetValues(headerName, out var contentValues))
×
294
            return contentValues != null ? string.Join(",", contentValues) : null;
×
295

296
        return null;
×
297
    }
298
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc