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:
@@ -0,0 +1,7 @@
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
public readonly record struct AuthResult(bool Success, string? Reason)
|
||||
{
|
||||
public static AuthResult Ok() => new(true, null);
|
||||
public static AuthResult Fail(string reason) => new(false, reason);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
public static class BearerVerifier
|
||||
{
|
||||
private const string Prefix = "Bearer ";
|
||||
|
||||
/// <summary>
|
||||
/// Compares the value of an Authorization header against an expected secret in fixed time.
|
||||
/// </summary>
|
||||
public static AuthResult Verify(string? authorizationHeader, string expectedSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expectedSecret))
|
||||
return AuthResult.Fail("server secret not configured");
|
||||
|
||||
if (string.IsNullOrEmpty(authorizationHeader))
|
||||
return AuthResult.Fail("missing Authorization header");
|
||||
|
||||
if (!authorizationHeader.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
return AuthResult.Fail("Authorization header is not a Bearer token");
|
||||
|
||||
var presented = authorizationHeader.AsSpan(Prefix.Length).Trim();
|
||||
var presentedBytes = Encoding.UTF8.GetBytes(presented.ToString());
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expectedSecret);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(presentedBytes, expectedBytes)
|
||||
? AuthResult.Ok()
|
||||
: AuthResult.Fail("bearer token mismatch");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace WebhookServer.Core.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled allow-list of IPs and CIDR ranges. Empty list = allow all.
|
||||
/// </summary>
|
||||
public sealed class IpAllowList
|
||||
{
|
||||
private readonly List<IPNetwork> _networks;
|
||||
|
||||
public bool IsEmpty => _networks.Count == 0;
|
||||
|
||||
private IpAllowList(List<IPNetwork> networks) => _networks = networks;
|
||||
|
||||
public bool Contains(IPAddress address)
|
||||
{
|
||||
if (IsEmpty) return true;
|
||||
|
||||
var normalized = Normalize(address);
|
||||
foreach (var net in _networks)
|
||||
{
|
||||
if (net.BaseAddress.AddressFamily != normalized.AddressFamily) continue;
|
||||
if (net.Contains(normalized)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a list of allowlist entries. Each entry may be a single IP or a CIDR.
|
||||
/// Throws <see cref="FormatException"/> on the first invalid entry.
|
||||
/// </summary>
|
||||
public static IpAllowList Parse(IEnumerable<string> entries)
|
||||
{
|
||||
var nets = new List<IPNetwork>();
|
||||
foreach (var raw in entries)
|
||||
{
|
||||
var entry = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(entry)) continue;
|
||||
nets.Add(ParseEntry(entry));
|
||||
}
|
||||
return new IpAllowList(nets);
|
||||
}
|
||||
|
||||
public static bool TryParse(IEnumerable<string> entries, out IpAllowList list, out string? error)
|
||||
{
|
||||
var nets = new List<IPNetwork>();
|
||||
foreach (var raw in entries)
|
||||
{
|
||||
var entry = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(entry)) continue;
|
||||
try
|
||||
{
|
||||
nets.Add(ParseEntry(entry));
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
list = new IpAllowList(new List<IPNetwork>());
|
||||
error = $"invalid entry '{raw}': {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
list = new IpAllowList(nets);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IPNetwork ParseEntry(string entry)
|
||||
{
|
||||
if (entry.Contains('/'))
|
||||
return IPNetwork.Parse(entry);
|
||||
|
||||
if (!IPAddress.TryParse(entry, out var addr))
|
||||
throw new FormatException($"'{entry}' is not a valid IP address or CIDR");
|
||||
|
||||
var prefix = addr.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
|
||||
return new IPNetwork(Normalize(addr), prefix);
|
||||
}
|
||||
|
||||
private static IPAddress Normalize(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
|
||||
return address.MapToIPv4();
|
||||
return address;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user