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

loresoft / HashGate / 17882037893

20 Sep 2025 04:09PM UTC coverage: 79.529%. First build
17882037893

push

github

pwelter34
Add .NET Standard 2.0 support and improve compatibility

118 of 182 branches covered (64.84%)

Branch coverage included in aggregate %.

11 of 19 new or added lines in 3 files covered. (57.89%)

321 of 370 relevant lines covered (86.76%)

12.8 hits per line

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

64.0
/src/HashGate.HttpClient/HttpRequestMessageExtensions.cs
1
using System.Security.Cryptography;
2

3
namespace HashGate.HttpClient;
4

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

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

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

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

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

75
        // ensure required headers are present
76
        if (signedHeaders == null)
3✔
77
            signedHeaders = HmacAuthenticationShared.DefaultSignedHeaders;
2✔
78
        else
79
            signedHeaders = [.. HmacAuthenticationShared.DefaultSignedHeaders.Union(signedHeaders)];
1✔
80

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

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

89
        // get header values
90
        var headerValues = GetHeaderValues(request, signedHeaders);
3✔
91

92
        // create string to sign
93
        var stringToSign = HmacAuthenticationShared.CreateStringToSign(
3!
94
            method: request.Method.Method,
3✔
95
            pathAndQuery: request.RequestUri?.PathAndQuery ?? string.Empty,
3✔
96
            headerValues: headerValues);
3✔
97

98
        // compute signature
99
        var signature = HmacAuthenticationShared.GenerateSignature(stringToSign, secret);
3✔
100

101
        // Build Authorization header
102
        var authorizationHeader = HmacAuthenticationShared.GenerateAuthorizationHeader(
3✔
103
            client: client,
3✔
104
            signedHeaders: signedHeaders,
3✔
105
            signature: signature);
3✔
106

107
        // Add Authorization header to request
108
        request.Headers.Add(HmacAuthenticationShared.AuthorizationHeaderName, authorizationHeader);
3✔
109
    }
3✔
110

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

145
        return request.AddHmacAuthentication(
1✔
146
            client: options.Client,
1✔
147
            secret: options.Secret,
1✔
148
            signedHeaders: options.SignedHeaders);
1✔
149
    }
150

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

190
        var bodyBytes = await request.Content.ReadAsByteArrayAsync();
5✔
191

192
#if NETSTANDARD2_0
193
        using var sha256 = SHA256.Create();
194
        var hashBytes = sha256.ComputeHash(bodyBytes);
195
#else
196
        var hashBytes = SHA256.HashData(bodyBytes);
5✔
197
#endif
198

199
        // consume the content stream, need to recreate it
200
        var originalContent = new ByteArrayContent(bodyBytes);
5✔
201
        foreach (var header in request.Content.Headers)
28✔
202
            originalContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
9✔
203

204
        // Restore content with headers
205
        request.Content = originalContent;
5✔
206

207
#if !NETSTANDARD2_0
208
        // 32 bytes SHA256 -> 44 chars base64
209
        Span<char> base64 = stackalloc char[44];
5✔
210
        if (Convert.TryToBase64Chars(hashBytes, base64, out int charsWritten))
5!
211
            return new string(base64[..charsWritten]);
5✔
212
#endif
213

214
        // if stackalloc is not large enough (should not happen for SHA256)
215
        return Convert.ToBase64String(hashBytes);
×
216
    }
8✔
217

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

238
        for (var i = 0; i < signedHeaders.Count; i++)
24✔
239
            headerValues[i] = GetHeaderValue(request, signedHeaders[i]) ?? string.Empty;
9!
240

241
        return headerValues;
3✔
242
    }
243

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

276
        if (headerName.Equals("content-type", StringComparison.InvariantCultureIgnoreCase))
6!
277
            return request.Content?.Headers.ContentType?.ToString();
×
278

279
        if (headerName.Equals("content-length", StringComparison.InvariantCultureIgnoreCase))
6!
280
            return request.Content?.Headers.ContentLength?.ToString();
×
281

282
        if (headerName.Equals("user-agent", StringComparison.InvariantCultureIgnoreCase))
6!
283
            return request.Headers.UserAgent.ToString();
×
284

285
        if (request.Headers.TryGetValues(headerName, out var values))
6!
286
            return values != null ? string.Join(",", values) : null;
6!
287

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

291
        return null;
×
292
    }
293
}
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