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

loresoft / HashGate / 25515201519

07 May 2026 06:42PM UTC coverage: 63.517% (-1.3%) from 64.826%
25515201519

push

github

pwelter34
Include client and endpoint in diagnostics

193 of 408 branches covered (47.3%)

Branch coverage included in aggregate %.

48 of 120 new or added lines in 2 files covered. (40.0%)

580 of 809 relevant lines covered (71.69%)

25.69 hits per line

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

67.52
/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

NEW
90
                return CompleteAuthentication(
×
NEW
91
                    activity: activity,
×
NEW
92
                    result: AuthenticateResult.Fail($"Invalid Authorization header: {result}"),
×
NEW
93
                    startTimestamp: startTimestamp,
×
NEW
94
                    outcome: "failure",
×
NEW
95
                    endpoint: endpoint,
×
NEW
96
                    client: client,
×
NEW
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

NEW
109
                return CompleteAuthentication(
×
NEW
110
                    activity: activity,
×
NEW
111
                    result: TooManySignedHeaders,
×
NEW
112
                    startTimestamp: startTimestamp,
×
NEW
113
                    outcome: "failure",
×
NEW
114
                    endpoint: endpoint,
×
NEW
115
                    client: client,
×
NEW
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

NEW
126
                return CompleteAuthentication(
×
NEW
127
                    activity: activity,
×
NEW
128
                    result: MissingRequiredSignedHeaders,
×
NEW
129
                    startTimestamp: startTimestamp,
×
NEW
130
                    outcome: "failure",
×
NEW
131
                    endpoint: endpoint,
×
NEW
132
                    client: client,
×
NEW
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

NEW
167
                return CompleteAuthentication(
×
NEW
168
                    activity: activity,
×
NEW
169
                    result: InvalidClientName,
×
NEW
170
                    startTimestamp: startTimestamp,
×
NEW
171
                    outcome: "failure",
×
NEW
172
                    endpoint: endpoint,
×
NEW
173
                    client: client,
×
NEW
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
                activity?.AddEvent(new ActivityEvent("hashgate.content_hash.failed"));
3!
182

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

193
            activity?.AddEvent(new ActivityEvent("hashgate.content_hash.validated"));
26!
194

195
            var headerValues = GetHeaderValues(hmacHeader.SignedHeaders);
26✔
196

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

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

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

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

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

NEW
236
                        return CompleteAuthentication(
×
NEW
237
                            activity: activity,
×
NEW
238
                            result: ReplayedSignature,
×
NEW
239
                            startTimestamp: startTimestamp,
×
NEW
240
                            outcome: "failure",
×
NEW
241
                            endpoint: endpoint,
×
NEW
242
                            client: client,
×
NEW
243
                            failureReason: "replayed_signature");
×
244
                    }
245

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

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

259
            var principal = new ClaimsPrincipal(identity);
21✔
260
            var ticket = new AuthenticationTicket(principal, Scheme.Name);
21✔
261

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

279
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
×
280
            activity?.AddException(ex);
×
281

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

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

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

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

322
        return result;
32✔
323
    }
324

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

330
        var contentHash = contentHashHeader.ToString();
28✔
331
        if (string.IsNullOrEmpty(contentHash))
28!
332
            return false;
×
333

334
        var computedHash = await GenerateContentHash().ConfigureAwait(false);
28✔
335

336
        return HmacAuthenticationShared.FixedTimeEquals(computedHash, contentHash);
28✔
337
    }
29✔
338

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

348
        requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
31✔
349
        var now = DateTimeOffset.UtcNow;
31✔
350

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

353
        // Use configured tolerance from options
354
        return timeDifference <= Options.ToleranceWindow;
31✔
355
    }
356

357

358
    private async Task<string> GenerateContentHash()
359
    {
360
        Request.EnableBuffering();
28✔
361

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

369
        using var sha = SHA256.Create();
6✔
370

371
        int read;
372
        var buffer = new byte[81920]; // default Stream.CopyTo buffer size
6✔
373

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

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

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

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

391
    private string[] GetHeaderValues(IReadOnlyList<string> signedHeaders)
392
    {
393
        var headerValues = new string[signedHeaders.Count];
26✔
394

395
        for (var i = 0; i < signedHeaders.Count; i++)
260✔
396
            headerValues[i] = GetHeaderValue(signedHeaders[i]) ?? string.Empty;
104✔
397

398
        return headerValues;
26✔
399
    }
400

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

408
            return Request.Host.Value;
×
409
        }
410

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

418
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.DateHeaderName, out var dateValue))
×
419
                return dateValue.ToString();
×
420

421
            return Request.Headers.Date.ToString();
×
422
        }
423

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

428
        if (headerName.Equals(HmacAuthenticationShared.ContentLengthHeaderName, StringComparison.OrdinalIgnoreCase))
109!
429
            return Request.ContentLength?.ToString(CultureInfo.InvariantCulture);
×
430

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

435
        return null;
2✔
436
    }
437

438
    private static bool ValidateRequiredSignedHeaders(IReadOnlyList<string> signedHeaders)
439
    {
440
        bool hasHost = false;
32✔
441
        bool hasTimestamp = false;
32✔
442
        bool hasContentHash = false;
32✔
443

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

454
        return hasHost && hasTimestamp && hasContentHash;
32✔
455
    }
456

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

464

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

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

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

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

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

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

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

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

489
    [LoggerMessage(Level = LogLevel.Error, Message = "Error during HMAC authentication: {ErrorMessage}")]
490
    private static partial void LogAuthenticationError(ILogger logger, Exception exception, string errorMessage);
491
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc