using System.Security.Cryptography; using System.Text; using WebhookServer.Core.Models; namespace WebhookServer.Core.Auth; public static class HmacVerifier { /// /// Compute the signature string (encoded per , no prefix) /// for the given body bytes and shared secret. /// public static string Compute( ReadOnlySpan body, string secret, HmacAlgorithm algorithm, HmacEncoding encoding) { var keyBytes = Encoding.UTF8.GetBytes(secret); Span hash = stackalloc byte[64]; // SHA-512 is 64 bytes max int written = algorithm switch { HmacAlgorithm.Sha1 => HMACSHA1.HashData(keyBytes, body, hash), HmacAlgorithm.Sha256 => HMACSHA256.HashData(keyBytes, body, hash), HmacAlgorithm.Sha512 => HMACSHA512.HashData(keyBytes, body, hash), _ => throw new ArgumentOutOfRangeException(nameof(algorithm)), }; var hashBytes = hash[..written]; return encoding switch { HmacEncoding.Hex => Convert.ToHexString(hashBytes).ToLowerInvariant(), HmacEncoding.Base64 => Convert.ToBase64String(hashBytes), _ => throw new ArgumentOutOfRangeException(nameof(encoding)), }; } /// /// Verify the HMAC signature in against the /// computed signature for . Strips the configured prefix /// before comparing. Comparison is constant time. /// public static AuthResult Verify( ReadOnlySpan body, string? presentedHeaderValue, HmacOptions options) { if (options.Secret.Plaintext is not { Length: > 0 } secret) return AuthResult.Fail("HMAC secret not available"); if (string.IsNullOrEmpty(presentedHeaderValue)) return AuthResult.Fail($"missing {options.HeaderName} header"); var presented = presentedHeaderValue.AsSpan().Trim(); if (!string.IsNullOrEmpty(options.Prefix)) { if (!presented.StartsWith(options.Prefix, StringComparison.OrdinalIgnoreCase)) return AuthResult.Fail("signature prefix mismatch"); presented = presented[options.Prefix.Length..]; } var expected = Compute(body, secret, options.Algorithm, options.Encoding); // Encoding for hex is case-insensitive in practice; normalize to lower. var presentedNormalized = options.Encoding == HmacEncoding.Hex ? presented.ToString().ToLowerInvariant() : presented.ToString(); var presentedBytes = Encoding.ASCII.GetBytes(presentedNormalized); var expectedBytes = Encoding.ASCII.GetBytes(expected); return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes) ? AuthResult.Ok() : AuthResult.Fail("HMAC signature mismatch"); } }