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

loresoft / HashGate / 25220934046

01 May 2026 03:43PM UTC coverage: 64.311% (-8.4%) from 72.756%
25220934046

push

github

pwelter34
Add OpenTelemetry diagnostics and metrics

189 of 398 branches covered (47.49%)

Branch coverage included in aggregate %.

101 of 165 new or added lines in 4 files covered. (61.21%)

539 of 734 relevant lines covered (73.43%)

27.38 hits per line

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

73.85
/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
        Activity? activity = null;
33✔
58

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

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

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

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

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

77
            activity?.SetTag(HashGateDiagnostics.AuthenticationSchemeTagName, Scheme.Name);
32!
78
            activity?.SetTag(HashGateDiagnostics.ReplayProtectionEnabledTagName, Options.EnableReplayProtection);
32!
79

80
            // invalid HMAC Authorization header format
81
            if (result != HmacHeaderError.None)
32!
82
            {
83
                LogInvalidAuthorizationHeader(Logger, result);
×
NEW
84
                return CompleteAuthentication(activity, AuthenticateResult.Fail($"Invalid Authorization header: {result}"), startTimestamp, "failure", result.ToString());
×
85
            }
86

87
            activity?.SetTag(HashGateDiagnostics.HmacClientTagName, hmacHeader.Client);
32!
88
            activity?.SetTag(HashGateDiagnostics.HmacSignedHeadersCountTagName, hmacHeader.SignedHeaders.Count);
32!
89

90
            // Reject requests with an excessive number of signed headers to prevent amplification.
91
            if (hmacHeader.SignedHeaders.Count > Options.MaxSignedHeaders)
32!
92
            {
93
                LogTooManySignedHeaders(Logger, hmacHeader.SignedHeaders.Count, Options.MaxSignedHeaders);
×
NEW
94
                return CompleteAuthentication(activity, TooManySignedHeaders, startTimestamp, "failure", "too_many_signed_headers");
×
95
            }
96

97
            // Enforce that critical headers are always cryptographically bound to the signature.
98
            // Without this, a custom client could omit host, x-timestamp, or x-content-sha256 from SignedHeaders,
99
            // weakening replay protection or allowing body/host substitution.
100
            if (!ValidateRequiredSignedHeaders(hmacHeader.SignedHeaders))
32!
101
            {
102
                LogMissingRequiredSignedHeaders(Logger);
×
NEW
103
                return CompleteAuthentication(activity, MissingRequiredSignedHeaders, startTimestamp, "failure", "missing_required_signed_headers");
×
104
            }
105

106
            if (!ValidateTimestamp(out var requestTime))
32✔
107
            {
108
                // Reject stale/future requests outside the allowed replay-protection window.
109
                LogInvalidTimestamp(Logger, requestTime);
3✔
110
                return CompleteAuthentication(activity, InvalidTimestampHeader, startTimestamp, "failure", "invalid_timestamp");
3✔
111
            }
112

113
            // Resolve keyed provider when configured; otherwise use the default registration.
114
            var keyProvider = string.IsNullOrEmpty(Options.ProviderServiceKey)
29!
115
                ? Context.RequestServices.GetRequiredService<IHmacKeyProvider>()
29✔
116
                : Context.RequestServices.GetRequiredKeyedService<IHmacKeyProvider>(Options.ProviderServiceKey);
29✔
117

118
            // Retrieve the client secret for the given client ID to verify the signature.
119
            // Done before body hashing so unknown clients are rejected cheaply without reading the body.
120
            var clientSecret = await keyProvider
29✔
121
                .GetSecretAsync(hmacHeader.Client, Context.RequestAborted)
29✔
122
                .ConfigureAwait(false);
29✔
123

124
            if (string.IsNullOrEmpty(clientSecret))
29!
125
            {
126
                // Unknown client IDs are treated as authentication failures.
127
                LogInvalidClientName(Logger, hmacHeader.Client);
×
NEW
128
                return CompleteAuthentication(activity, InvalidClientName, startTimestamp, "failure", "invalid_client");
×
129
            }
130

131
            if (!await ValidateContentHash())
29✔
132
            {
133
                // Ensure the request body hash matches what the client signed.
134
                LogInvalidContentHash(Logger);
3✔
135
                activity?.AddEvent(new ActivityEvent("hashgate.content_hash.failed"));
3!
136
                return CompleteAuthentication(activity, InvalidContentHashHeader, startTimestamp, "failure", "invalid_content_hash");
3✔
137
            }
138

139
            activity?.AddEvent(new ActivityEvent("hashgate.content_hash.validated"));
26!
140

141
            var headerValues = GetHeaderValues(hmacHeader.SignedHeaders);
26✔
142

143
            // Recreate the canonical payload exactly as the client signed it before signature verification.
144
            var stringToSign = HmacAuthenticationShared.CreateStringToSign(
26✔
145
                method: Request.Method,
26✔
146
                pathAndQuery: Request.Path + Request.QueryString,
26✔
147
                headerValues: headerValues);
26✔
148

149
            // Generate the expected signature using the client secret and compare it to the signature provided by the client.
150
            var expectedSignature = HmacAuthenticationShared.GenerateSignature(stringToSign, clientSecret);
26✔
151
            if (!HmacAuthenticationShared.FixedTimeEquals(expectedSignature, hmacHeader.Signature))
26✔
152
            {
153
                // Use constant-time comparison to avoid timing side-channel leakage.
154
                LogInvalidSignature(Logger, hmacHeader.Client);
5✔
155
                return CompleteAuthentication(activity, InvalidSignature, startTimestamp, "failure", "invalid_signature");
5✔
156
            }
157

158
            if (Options.EnableReplayProtection)
21✔
159
            {
160
                var replayProtection = Context.RequestServices.GetService<IHmacReplayProtection>();
21✔
161
                if (replayProtection is not null)
21!
162
                {
163
                    // The signature is valid until the far edge of the tolerance window from the request timestamp.
164
                    var signatureExpiry = requestTime!.Value.AddMinutes(Options.ToleranceWindow);
21✔
165
                    var isNew = await replayProtection
21✔
166
                        .TryStoreAsync(hmacHeader.Signature, signatureExpiry, Context.RequestAborted)
21✔
167
                        .ConfigureAwait(false);
21✔
168

169
                    if (!isNew)
21!
170
                    {
171
                        LogReplayedSignature(Logger, hmacHeader.Client);
×
NEW
172
                        activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "replay");
×
NEW
173
                        return CompleteAuthentication(activity, ReplayedSignature, startTimestamp, "failure", "replayed_signature");
×
174
                    }
175

176
                    activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "new");
21!
177
                }
178
                else
179
                {
NEW
180
                    activity?.SetTag(HashGateDiagnostics.ReplayProtectionResultTagName, "not_configured");
×
181
                }
182
            }
183

184
            // At this point, the request is authenticated successfully. Create a claims identity and principal for authorization.
185
            var identity = await keyProvider
21✔
186
                .GenerateClaimsAsync(hmacHeader.Client, Scheme.Name, Context.RequestAborted)
21✔
187
                .ConfigureAwait(false);
21✔
188

189
            var principal = new ClaimsPrincipal(identity);
21✔
190
            var ticket = new AuthenticationTicket(principal, Scheme.Name);
21✔
191

192
            // Return a successful authentication ticket so authorization can evaluate policies.
193
            return CompleteAuthentication(activity, AuthenticateResult.Success(ticket), startTimestamp, "success");
21✔
194
        }
NEW
195
        catch (OperationCanceledException) when (Context.RequestAborted.IsCancellationRequested)
×
196
        {
NEW
197
            throw;
×
198
        }
199
        catch (Exception ex)
×
200
        {
201
            LogAuthenticationError(Logger, ex, ex.Message);
×
202

NEW
203
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
×
NEW
204
            activity?.AddException(ex);
×
205

NEW
206
            return CompleteAuthentication(activity, AuthenticationError, startTimestamp, "failure", "authentication_error");
×
207
        }
208
        finally
209
        {
210
            activity?.Dispose();
33!
211
        }
212
    }
33✔
213

214
    private AuthenticateResult CompleteAuthentication(
215
        Activity? activity,
216
        AuthenticateResult result,
217
        long startTimestamp,
218
        string outcome,
219
        string? failureReason = null)
220
    {
221
        activity?.SetTag(HashGateDiagnostics.AuthenticationResultTagName, outcome);
32!
222

223
        if (failureReason is not null)
32✔
224
        {
225
            activity?.SetTag(HashGateDiagnostics.AuthenticationFailureReasonTagName, failureReason);
11!
226
            activity?.SetStatus(ActivityStatusCode.Error, failureReason);
11!
227
        }
228

229
        HashGateDiagnostics.RecordAuthentication(Scheme.Name, outcome, failureReason, startTimestamp);
32✔
230

231
        return result;
32✔
232
    }
233

234

235
    private async Task<bool> ValidateContentHash()
236
    {
237
        if (!Request.Headers.TryGetValue(HmacAuthenticationShared.ContentHashHeaderName, out var contentHashHeader))
29✔
238
            return false;
1✔
239

240
        var contentHash = contentHashHeader.ToString();
28✔
241
        if (string.IsNullOrEmpty(contentHash))
28!
242
            return false;
×
243

244
        var computedHash = await GenerateContentHash().ConfigureAwait(false);
28✔
245

246
        return HmacAuthenticationShared.FixedTimeEquals(computedHash, contentHash);
28✔
247
    }
29✔
248

249
    private bool ValidateTimestamp(out DateTimeOffset? requestTime)
250
    {
251
        var timestampHeader = GetHeaderValue(HmacAuthenticationShared.TimeStampHeaderName);
32✔
252
        if (!long.TryParse(timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestamp))
32✔
253
        {
254
            requestTime = default;
1✔
255
            return false;
1✔
256
        }
257

258
        requestTime = DateTimeOffset.FromUnixTimeSeconds(timestamp);
31✔
259
        var now = DateTimeOffset.UtcNow;
31✔
260

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

263
        // Use configured tolerance from options
264
        return timeDifference <= Options.ToleranceWindow;
31✔
265
    }
266

267

268
    private async Task<string> GenerateContentHash()
269
    {
270
        Request.EnableBuffering();
28✔
271

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

279
        using var sha = SHA256.Create();
6✔
280

281
        int read;
282
        var buffer = new byte[81920]; // default Stream.CopyTo buffer size
6✔
283

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

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

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

294
        // Convert the hash to a Base64 string. Use TryToBase64Chars for better performance and less memory allocation.
295
        Span<char> base64 = stackalloc char[44];
6✔
296
        return Convert.TryToBase64Chars(sha.Hash!, base64, out int written)
6!
297
            ? new string(base64[..written])
6✔
298
            : Convert.ToBase64String(sha.Hash!);
6✔
299
    }
28✔
300

301
    private string[] GetHeaderValues(IReadOnlyList<string> signedHeaders)
302
    {
303
        var headerValues = new string[signedHeaders.Count];
26✔
304

305
        for (var i = 0; i < signedHeaders.Count; i++)
260✔
306
            headerValues[i] = GetHeaderValue(signedHeaders[i]) ?? string.Empty;
104✔
307

308
        return headerValues;
26✔
309
    }
310

311
    private string? GetHeaderValue(string headerName)
312
    {
313
        if (headerName.Equals(HmacAuthenticationShared.HostHeaderName, StringComparison.OrdinalIgnoreCase))
136✔
314
        {
315
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.HostHeaderName, out var hostValue))
26!
316
                return hostValue.ToString();
26✔
317

318
            return Request.Host.Value;
×
319
        }
320

321
        // Handle date headers specifically
322
        if (headerName.Equals(HmacAuthenticationShared.DateHeaderName, StringComparison.OrdinalIgnoreCase)
110!
323
            || headerName.Equals(HmacAuthenticationShared.DateOverrideHeaderName, StringComparison.OrdinalIgnoreCase))
110✔
324
        {
325
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.DateOverrideHeaderName, out var xDateValue))
×
326
                return xDateValue.ToString();
×
327

328
            if (Request.Headers.TryGetValue(HmacAuthenticationShared.DateHeaderName, out var dateValue))
×
329
                return dateValue.ToString();
×
330

331
            return Request.Headers.Date.ToString();
×
332
        }
333

334
        // Handle content-type and content-length headers specifically
335
        if (headerName.Equals(HmacAuthenticationShared.ContentTypeHeaderName, StringComparison.OrdinalIgnoreCase))
110✔
336
            return Request.ContentType?.ToString();
1!
337

338
        if (headerName.Equals(HmacAuthenticationShared.ContentLengthHeaderName, StringComparison.OrdinalIgnoreCase))
109!
339
            return Request.ContentLength?.ToString(CultureInfo.InvariantCulture);
×
340

341
        // For all other headers, try to get the value directly
342
        if (Request.Headers.TryGetValue(headerName, out var value))
109✔
343
            return value.ToString();
107✔
344

345
        return null;
2✔
346
    }
347

348
    private static bool ValidateRequiredSignedHeaders(IReadOnlyList<string> signedHeaders)
349
    {
350
        bool hasHost = false;
32✔
351
        bool hasTimestamp = false;
32✔
352
        bool hasContentHash = false;
32✔
353

354
        for (int i = 0; i < signedHeaders.Count; i++)
320✔
355
        {
356
            if (signedHeaders[i].Equals(HmacAuthenticationShared.HostHeaderName, StringComparison.OrdinalIgnoreCase))
128✔
357
                hasHost = true;
32✔
358
            else if (signedHeaders[i].Equals(HmacAuthenticationShared.TimeStampHeaderName, StringComparison.OrdinalIgnoreCase))
96✔
359
                hasTimestamp = true;
32✔
360
            else if (signedHeaders[i].Equals(HmacAuthenticationShared.ContentHashHeaderName, StringComparison.OrdinalIgnoreCase))
64✔
361
                hasContentHash = true;
32✔
362
        }
363

364
        return hasHost && hasTimestamp && hasContentHash;
32✔
365
    }
366

367

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

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

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

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

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

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

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

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

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