Initial WebhookServer implementation

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>
This commit is contained in:
2026-05-07 22:04:52 -04:00
parent 2f61b342af
commit 8ecfe84540
62 changed files with 3721 additions and 0 deletions
@@ -0,0 +1,76 @@
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");
}
}