8ecfe84540
Add the .NET 8 solution scaffolded against PLAN.md. Three projects share WebhookServer.Core (models, auth, execution, storage, IPC, callbacks) and WebhookServer.Service hosts an embedded Kestrel listener plus the named-pipe admin server. WebhookServer.Gui is a thin MVVM client over the pipe. Includes 25 unit tests covering HMAC verification, bearer auth, IP allowlist parsing, arg-template rendering, DPAPI round-trip, and the encrypt-on-save config store. Install/uninstall PowerShell scripts default to LocalSystem and accept a domain user or gMSA via -ServiceAccount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
77 lines
2.9 KiB
C#
77 lines
2.9 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using WebhookServer.Core.Models;
|
|
|
|
namespace WebhookServer.Core.Auth;
|
|
|
|
public static class HmacVerifier
|
|
{
|
|
/// <summary>
|
|
/// Compute the signature string (encoded per <paramref name="encoding"/>, no prefix)
|
|
/// for the given body bytes and shared secret.
|
|
/// </summary>
|
|
public static string Compute(
|
|
ReadOnlySpan<byte> body,
|
|
string secret,
|
|
HmacAlgorithm algorithm,
|
|
HmacEncoding encoding)
|
|
{
|
|
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
|
Span<byte> 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)),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify the HMAC signature in <paramref name="presentedHeaderValue"/> against the
|
|
/// computed signature for <paramref name="body"/>. Strips the configured prefix
|
|
/// before comparing. Comparison is constant time.
|
|
/// </summary>
|
|
public static AuthResult Verify(
|
|
ReadOnlySpan<byte> 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");
|
|
}
|
|
}
|