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

loresoft / HashGate / 25560396259

08 May 2026 02:12PM UTC coverage: 63.735% (-0.02%) from 63.75%
25560396259

push

github

pwelter34
Remove event assertion from DiagnosticsTests

190 of 402 branches covered (47.26%)

Branch coverage included in aggregate %.

571 of 792 relevant lines covered (72.1%)

26.11 hits per line

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

67.54
/src/HashGate.AspNetCore/HmacAuthenticationHandler.cs
1
// Ignore Spelling: timestamp Hmac
2

3
using System.Diagnostics;
4
using System.Globalization;
5
using System.Security.Claims;
6
using System.Security.Cryptography;
7
using System.Text.Encodings.Web;
8

9
using Microsoft.AspNetCore.Authentication;
10
using Microsoft.AspNetCore.Http;
11
using Microsoft.Extensions.DependencyInjection;
12
using Microsoft.Extensions.Logging;
13
using Microsoft.Extensions.Options;
14

15
namespace HashGate.AspNetCore;
16

17
/// <summary>
18
/// Handles HMAC authentication for incoming HTTP requests.
19
/// </summary>
20
/// <remarks>
21
/// This handler validates the HMAC signature in the Authorization header, checks the timestamp for replay protection,
22
/// retrieves the client secret using <see cref="IHmacKeyProvider"/>, and authenticates the request if all checks pass.
23
/// </remarks>
24
public partial class HmacAuthenticationHandler : AuthenticationHandler<HmacAuthenticationSchemeOptions>
25
{
26
    private static readonly AuthenticateResult InvalidTimestampHeader = AuthenticateResult.Fail("Invalid timestamp header");
1✔
27
    private static readonly AuthenticateResult InvalidContentHashHeader = AuthenticateResult.Fail("Invalid content hash header");
1✔
28
    private static readonly AuthenticateResult InvalidClientName = AuthenticateResult.Fail("Invalid client name");
1✔
29
    private static readonly AuthenticateResult InvalidSignature = AuthenticateResult.Fail("Invalid signature");
1✔
30
    private static readonly AuthenticateResult MissingRequiredSignedHeaders = AuthenticateResult.Fail("Missing required signed headers");
1✔
31
    private static readonly AuthenticateResult TooManySignedHeaders = AuthenticateResult.Fail("Too many signed headers");
1✔
32
    private static readonly AuthenticateResult ReplayedSignature = AuthenticateResult.Fail("Replayed signature");
1✔
33
    private static readonly AuthenticateResult AuthenticationError = AuthenticateResult.Fail("Authentication error");
1✔
34

35
    /// <summary>
36
    /// Initializes a new instance of the <see cref="HmacAuthenticationHandler"/> class.
37
    /// </summary>
38
    /// <param name="options">The options monitor for <see cref="HmacAuthenticationSchemeOptions"/>.</param>
39
    /// <param name="logger">The logger factory.</param>
40
    /// <param name="encoder">The URL encoder.</param>
41
    public HmacAuthenticationHandler(
42
        IOptionsMonitor<HmacAuthenticationSchemeOptions> options,
43
        ILoggerFactory logger,
44
        UrlEncoder encoder)
45
        : base(options, logger, encoder)
33✔
46
    { }
33✔
47

48
    /// <summary>
49
    /// Handles the authentication process for HMAC authentication.
50
    /// </summary>
51
    /// <returns>
52
    /// A <see cref="Task{AuthenticateResult}"/> representing the asynchronous authentication operation.
53
    /// </returns>
54
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
55
    {
56
        var startTimestamp = 0L;
33✔
57
        string? client = null;
33✔
58
        string? endpoint = null;
33✔
59
        Activity? activity = null;
33✔
60

61
        try
62
        {
63
            var authorizationHeader = Request.Headers.Authorization.ToString();
33✔
64

65
            // If no Authorization header is present, return no result
66
            if (string.IsNullOrEmpty(authorizationHeader))
33✔
67
                return AuthenticateResult.NoResult();
1✔
68

69
            // Try to parse the HMAC Authorization header
70
            var result = HmacHeaderParser.TryParse(authorizationHeader, true, out var hmacHeader);
32✔
71

72
            // not an HMAC Authorization header, return no result
73
            if (result == HmacHeaderError.InvalidSchema)
32!
74
                return AuthenticateResult.NoResult();
×
75

76
            startTimestamp = Stopwatch.GetTimestamp();
32✔
77
            activity = HashGateDiagnostics.ActivitySource.StartActivity("HashGate.Authenticate", ActivityKind.Internal);
32✔
78

79
            endpoint = GetEndpoint();
32✔
80

81
            activity?.SetTag(HashGateDiagnostics.AuthenticationSchemeTagName, Scheme.Name);
32!
82
            activity?.SetTag(HashGateDiagnostics.ReplayProtectionEnabledTagName, Options.EnableReplayProtection);
32!
83
            activity?.SetTag(HashGateDiagnostics.EndpointTagName, endpoint);
32!
84

85
            // invalid HMAC Authorization header format
86
            if (result != HmacHeaderError.None)
32!
87
            {
88
                LogInvalidAuthorizationHeader(Logger, result);
×
89

90
                return CompleteAuthentication(
×
91
                    activity: activity,
×
92
                    result: AuthenticateResult.Fail($"Invalid Authorization header: {result}"),
×
93
                    startTimestamp: startTimestamp,
×
94
                    outcome: "failure",
×
95
                    endpoint: endpoint,
×
96
                    client: client,
×
97
                    failureReason: result.ToString());
×
98
            }
99

100
            client = hmacHeader.Client;
32✔
101
            activity?.SetTag(HashGateDiagnostics.ClientTagName, client);
32!
102
            activity?.SetTag(HashGateDiagnostics.HmacSignedHeadersCountTagName, hmacHeader.SignedHeaders.Count);
32!
103

104
            // Reject requests with an excessive number of signed headers to prevent amplification.
105
            if (hmacHeader.SignedHeaders.Count > Options.MaxSignedHeaders)
32!
106
            {
107
                LogTooManySignedHeaders(Logger, hmacHeader.SignedHeaders.Count, Options.MaxSignedHeaders);
×
108

109
                return CompleteAuthentication(
×
110
                    activity: activity,
×
111
                    result: TooManySignedHeaders,
×
112
                    startTimestamp: startTimestamp,
×
113
                    outcome: "failure",
×
114
                    endpoint: endpoint,
×
115
                    client: client,
×
116
                    failureReason: "too_many_signed_headers");
×
117
            }
118

119
            // Enforce that critical headers are always cryptographically bound to the signature.
120
            // Without this, a custom client could omit host, x-timestamp, or x-content-sha256 from SignedHeaders,
121
            // weakening replay protection or allowing body/host substitution.
122
            if (!ValidateRequiredSignedHeaders(hmacHeader.SignedHeaders))
32!
123
            {
124
                LogMissingRequiredSignedHeaders(Logger);
×
125

126
                return CompleteAuthentication(
×
127
                    activity: activity,
×
128
                    result: MissingRequiredSignedHeaders,
×
129
                    startTimestamp: startTimestamp,
×
130
                    outcome: "failure",
×
131
                    endpoint: endpoint,
×
132
                    client: client,
×
133
                    failureReason: "missing_required_signed_headers");
×
134
            }
135

136
            if (!ValidateTimestamp(out var requestTime))
32✔
137
            {
138
                // Reject stale/future requests outside the allowed replay-protection window.
139
                LogInvalidTimestamp(Logger, requestTime);
3✔
140

141
                return CompleteAuthentication(
3✔
142
                    activity: activity,
3✔
143
                    result: InvalidTimestampHeader,
3✔
144
                    startTimestamp: startTimestamp,
3✔
145
                    outcome: "failure",
3✔
146
                    endpoint: endpoint,
3✔
147
                    client: client,
3✔
148
                    failureReason: "invalid_timestamp");
3✔
149
            }
150

151
            // Resolve keyed provider when configured; otherwise use the default registration.
152
            var keyProvider = string.IsNullOrEmpty(Options.ProviderServiceKey)
29!
153
                ? Context.RequestServices.GetRequiredService<IHmacKeyProvider>()
29✔
154
                : Context.RequestServices.GetRequiredKeyedService<IHmacKeyProvider>(Options.ProviderServiceKey);
29✔
155

156
            // Retrieve the client secret for the given client ID to verify the signature.
157
            // Done before body hashing so unknown clients are rejected cheaply without reading the body.
158
            var clientSecret = await keyProvider
29✔
159
                .GetSecretAsync(hmacHeader.Client, Context.RequestAborted)
29✔
160
                .ConfigureAwait(false);
29✔
161

162
            if (string.IsNullOrEmpty(clientSecret))
29!
163
            {
164
                // Unknown client IDs are treated as authentication failures.
165
                LogInvalidClientName(Logger, hmacHeader.Client);
×
166

167
                return CompleteAuthentication(
×
168
                    activity: activity,
×
169
                    result: InvalidClientName,
×
170
                    startTimestamp: startTimestamp,
×
171
                    outcome: "failure",
×
172
                    endpoint: endpoint,
×
173
                    client: client,
×
174
                    failureReason: "invalid_client");
×
175
            }
176

177
            if (!await ValidateContentHash())
29✔
178
            {
179
                // Ensure the request body hash matches what the client signed.
180
                LogInvalidContentHash(Logger);
3✔
181

182
                return CompleteAuthentication(
3✔
183
                    activity: activity,
3✔
184
                    result: InvalidContentHashHeader,
3✔
185
                    startTimestamp: startTimestamp,
3✔
186
                    outcome: "failure",
3✔
187
                    endpoint: endpoint,
3✔
188
                    client: client,
3✔
189
                    failureReason: "invalid_content_hash");
3✔
190
            }
191

192
            var headerValues = GetHeaderValues(hmacHeader.SignedHeaders);
26✔
193

194
            // Recreate the canonical payload exactly as the client signed it before signature verification.
195
            var stringToSign = HmacAuthenticationShared.CreateStringToSign(
26✔
196
                method: Request.Method,
26✔
197
                pathAndQuery: Request.Path + Request.QueryString,
26✔
198
                headerValues: headerValues);
26✔
199

200
            // Generate the expected signature using the client secret and compare it to the signature provided by the client.
201
            var expectedSignature = HmacAuthenticationShared.GenerateSignature(stringToSign, clientSecret);
26✔
202
            if (!HmacAuthenticationShared.FixedTimeEquals(expectedSignature, hmacHeader.Signature))
26✔
203
            {
204
                // Use constant-time comparison to avoid timing side-channel leakage.
205
                LogInvalidSignature(Logger, hmacHeader.Client);
5✔
206

207
                return CompleteAuthentication(
5✔
208
                    activity: activity,
5✔
209
                    result: InvalidSignature,
5✔
210
                    startTimestamp: startTimestamp,
5✔
211
                    outcome: "failure",
5✔
212
                    endpoint: endpoint,
5✔
213
                    client: client,
5✔
214
                    failureReason: "invalid_signature");
5✔
215
            }
216

217
            if (Options.EnableReplayProtection)
21✔
218
            {
219
                var replayProtection = Context.RequestServices.GetService<IHmacReplayProtection>();
21✔
220
                if (replayProtection is not null)
21!
221
                {
222
                    // The signature is valid until the far edge of the tolerance window from the request timestamp.
223
                    var signatureExpiry = requestTime!.Value.AddMinutes(Options.ToleranceWindow);
21✔
224
                    var isNew = await replayProtection
21✔
225
                        .TryStoreAsync(hmacHeader.Signature, signatureExpiry, Context.RequestAborted)
21✔
226
                        .ConfigureAwait(false);
21✔
227

228
                    if (!isNew)
21!
229
                    {
230
                        LogReplayedSignature(Logger, hmacHeader.Client);
×
231
                        activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "replay");
×
232

233
                        return CompleteAuthentication(
×
234
                            activity: activity,
×
235
                            result: ReplayedSignature,
×
236
                            startTimestamp: startTimestamp,
×
237
                            outcome: "failure",
×
238
                            endpoint: endpoint,
×
239
                            client: client,
×
240
                            failureReason: "replayed_signature");
×
241
                    }
242

243
                    activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "new");
21!
244
                }
245
                else
246
                {
247
                    activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "not_configured");
×
248
                }
249
            }
250

251
            // At this point, the request is authenticated successfully. Create a claims identity and principal for authorization.
252
            var identity = await keyProvider
21✔
253
                .GenerateClaimsAsync(hmacHeader.Client, Scheme.Name, Context.RequestAborted)
21✔
254
                .ConfigureAwait(false);
21✔
255

256
            var principal = new ClaimsPrincipal(identity);
21✔
257
            var ticket = new AuthenticationTicket(principal, Scheme.Name);
21✔
258

259
            // Return a successful authentication ticket so authorization can evaluate policies.
260
            return CompleteAuthentication(
21✔
261
                activity: activity,
21✔
262
                result: AuthenticateResult.Success(ticket),
21✔
263
                startTimestamp: startTimestamp,
21✔
264
                outcome: "success",
21✔
265
                endpoint: endpoint,
21✔
266
                client: client);
21✔
267
        }
268
        catch (OperationCanceledException) when (Context.RequestAborted.IsCancellationRequested)
×
269
        {
270
            throw;
×
271
        }
272
        catch (Exception ex)
×
273
        {
274
            LogAuthenticationError(Logger, ex, ex.Message);
×
275

276
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
×
277
            activity?.AddException(ex);
×
278

279
            return CompleteAuthentication(
×
280
                activity: activity,
×
281
                result: AuthenticationError,
×
282
                startTimestamp: startTimestamp,
×
283
                outcome: "failure",
×
284
                endpoint: endpoint,
×
285
                client: client,
×
286
                failureReason: "authentication_error");
×
287
        }
288
        finally
289
        {
290
            activity?.Dispose();
33!
291
        }
292
    }
33✔
293

294
    private AuthenticateResult CompleteAuthentication(
295
        Activity? activity,
296
        AuthenticateResult result,
297
        long startTimestamp,
298
        string outcome,
299
        string? endpoint,
300
        string? client,
301
        string? failureReason = null)
302
    {
303
        activity?.SetTag(HashGateDiagnostics.AuthenticationResultTagName, outcome);
32!
304

305
        if (failureReason is not null)
32✔
306
        {
307
            activity?.SetTag(HashGateDiagnostics.AuthenticationFailureReasonTagName, failureReason);
11!
308
            activity?.SetStatus(ActivityStatusCode.Error, failureReason);
11!
309
        }
310

311
        HashGateDiagnostics.RecordAuthentication(
32✔
312
            scheme: Scheme.Name,
32✔
313
            result: outcome,
32✔
314
            failureReason: failureReason,
32✔
315
            elapsedTicks: startTimestamp,
32✔
316
            endpoint: endpoint,
32✔
317
            client: client);
32✔
318

319
        return result;
32✔
320
    }
321

322
    private async Task<bool> ValidateContentHash()
323
    {
324
        if (!Request.Headers.TryGetValue(HmacAuthenticationShared.ContentHashHeaderName, out var contentHashHeader))
29✔
325
            return false;
1✔
326

327
        var contentHash = contentHashHeader.ToString();
28✔
328
        if (string.IsNullOrEmpty(contentHash))
28!
329
            return false;
×
330

331
        var computedHash = await GenerateContentHash().ConfigureAwait(false);
28✔
332

333
        return HmacAuthenticationShared.FixedTimeEquals(computedHash, contentHash);
28✔
334
    }
29✔
335

336
    private bool ValidateTimestamp(out DateTimeOffset? requestTime)
337
    {
338
        var timestampHeader = GetHeaderValue(HmacAuthenticationShared.TimeStampHeaderName);
32✔
339
        if (!long.TryParse(timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestamp))
32✔
340
        {
341
            requestTime = default;
1✔
342
            return false;
1✔
343
        }
344

345
        requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
31✔
346
        var now = DateTimeOffset.UtcNow;
31✔
347

348
        var timeDifference = Math.Abs((now - requestTime.Value).TotalMinutes);
31✔
349

350
        // Use configured tolerance from options
351
        return timeDifference <= Options.ToleranceWindow;
31✔
352
    }
353

354

355
    private async Task<string> GenerateContentHash()
356
    {
357
        Request.EnableBuffering();
28✔
358

359
        // Do not trust the Content-Length header to determine whether a body exists.
360
        // A malicious client or proxy could send Content-Length: 0 with an actual body,
361
        // causing us to return the empty hash while the application later reads real content.
362
        // Instead, only short-circuit for Stream.Null (no body stream at all).
363
        if (Request.Body == Stream.Null)
28✔
364
            return HmacAuthenticationShared.EmptyContentHash;
22✔
365

366
        using var sha = SHA256.Create();
6✔
367

368
        int read;
369
        var buffer = new byte[81920]; // default Stream.CopyTo buffer size
6✔
370

371
        // Read the request body in chunks to compute the hash without loading the entire body into memory.
372
        while ((read = await Request.Body.ReadAsync(buffer, Context.RequestAborted)) > 0)
12✔
373
            sha.TransformBlock(buffer, 0, read, null, 0);
6✔
374

375
        // Finalize the hash computation. Since TransformBlock was used, we need to call TransformFinalBlock with an empty array.
376
        sha.TransformFinalBlock([], 0, 0);
6✔
377

378
        // Reset the request body stream position so it can be read again by the application after authentication.
379
        Request.Body.Position = 0;
6✔
380

381
        // Convert the hash to a Base64 string. Use TryToBase64Chars for better performance and less memory allocation.
382
        Span<char> base64 = stackalloc char[44];
6✔
383
        return Convert.TryToBase64Chars(sha.Hash!, base64, out int written)
6!
384
            ? new string(base64[..written])
6✔
385
            : Convert.ToBase64String(sha.Hash!);
6✔
386
    }
28✔
387

388
    private string[] GetHeaderValues(IReadOnlyList<string> signedHeaders)
389
    {
390
        var headerValues = new string[signedHeaders.Count];
26✔
391

392
        for (var i = 0; i < signedHeaders.Count; i++)
260✔
393
            headerValues[i] = GetHeaderValue(signedHeaders[i]) ?? string.Empty;
104✔
394

395
        return headerValues;
26✔
396
    }
397

398
    private string? GetHeaderValue(string headerName)
399
    {
400
        if (headerName.Equals(HmacAuthenticationShared.HostHeaderName, StringComparison.OrdinalIgnoreCase))
136✔
401
        {
402
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.HostHeaderName, out var hostValue))
26!
403
                return hostValue.ToString();
26✔
404

405
            return Request.Host.Value;
×
406
        }
407

408
        // Handle date headers specifically
409
        if (headerName.Equals(HmacAuthenticationShared.DateHeaderName, StringComparison.OrdinalIgnoreCase)
110!
410
            || headerName.Equals(HmacAuthenticationShared.DateOverrideHeaderName, StringComparison.OrdinalIgnoreCase))
110✔
411
        {
412
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.DateOverrideHeaderName, out var xDateValue))
×
413
                return xDateValue.ToString();
×
414

415
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.DateHeaderName, out var dateValue))
×
416
                return dateValue.ToString();
×
417

418
            return Request.Headers.Date.ToString();
×
419
        }
420

421
        // Handle content-type and content-length headers specifically
422
        if (headerName.Equals(HmacAuthenticationShared.ContentTypeHeaderName, StringComparison.OrdinalIgnoreCase))
110✔
423
            return Request.ContentType?.ToString();
1!
424

425
        if (headerName.Equals(HmacAuthenticationShared.ContentLengthHeaderName, StringComparison.OrdinalIgnoreCase))
109!
426
            return Request.ContentLength?.ToString(CultureInfo.InvariantCulture);
×
427

428
        // For all other headers, try to get the value directly
429
        if (Request.Headers.TryGetValue(headerName, out var value))
109✔
430
            return value.ToString();
107✔
431

432
        return null;
2✔
433
    }
434

435
    private static bool ValidateRequiredSignedHeaders(IReadOnlyList<string> signedHeaders)
436
    {
437
        bool hasHost = false;
32✔
438
        bool hasTimestamp = false;
32✔
439
        bool hasContentHash = false;
32✔
440

441
        for (int i = 0; i < signedHeaders.Count; i++)
320✔
442
        {
443
            if (signedHeaders[i].Equals(HmacAuthenticationShared.HostHeaderName, StringComparison.OrdinalIgnoreCase))
128✔
444
                hasHost = true;
32✔
445
            else if (signedHeaders[i].Equals(HmacAuthenticationShared.TimeStampHeaderName, StringComparison.OrdinalIgnoreCase))
96✔
446
                hasTimestamp = true;
32✔
447
            else if (signedHeaders[i].Equals(HmacAuthenticationShared.ContentHashHeaderName, StringComparison.OrdinalIgnoreCase))
64✔
448
                hasContentHash = true;
32✔
449
        }
450

451
        return hasHost && hasTimestamp && hasContentHash;
32✔
452
    }
453

454
    private string GetEndpoint()
455
    {
456
        return Context.GetEndpoint()?.DisplayName
32!
457
            ?? Request.Path.Value
32✔
458
            ?? "unknown";
32✔
459
    }
460

461

462
    [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid Authorization header: {HeaderError}")]
463
    private static partial void LogInvalidAuthorizationHeader(ILogger logger, HmacHeaderError headerError);
464

465
    [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid or expired timestamp: {RequestTime}")]
466
    private static partial void LogInvalidTimestamp(ILogger logger, DateTimeOffset? requestTime);
467

468
    [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid body content hash")]
469
    private static partial void LogInvalidContentHash(ILogger logger);
470

471
    [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid client name: {Client}")]
472
    private static partial void LogInvalidClientName(ILogger logger, string client);
473

474
    [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid signature for client: {Client}")]
475
    private static partial void LogInvalidSignature(ILogger logger, string client);
476

477
    [LoggerMessage(Level = LogLevel.Warning, Message = "Missing required signed headers: host, x-timestamp, and x-content-sha256 must be included in SignedHeaders")]
478
    private static partial void LogMissingRequiredSignedHeaders(ILogger logger);
479

480
    [LoggerMessage(Level = LogLevel.Warning, Message = "Too many signed headers: {Count} exceeds maximum of {Max}")]
481
    private static partial void LogTooManySignedHeaders(ILogger logger, int count, int max);
482

483
    [LoggerMessage(Level = LogLevel.Warning, Message = "Replayed signature detected for client: {Client}")]
484
    private static partial void LogReplayedSignature(ILogger logger, string client);
485

486
    [LoggerMessage(Level = LogLevel.Error, Message = "Error during HMAC authentication: {ErrorMessage}")]
487
    private static partial void LogAuthenticationError(ILogger logger, Exception exception, string errorMessage);
488
}
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