From 4446288bb62b28ce44ff0edd88a3044617084ad5 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 12 May 2026 16:25:59 -0400 Subject: [PATCH] feat(altcha): add Svrnty.CQRS.Altcha.Grpc with default verifier + proto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default transport for IAltchaVerifier / IAltchaChallengeProvider — calls a self-hosted altcha service over gRPC. Wire contract - Protos/altcha.proto defines svrnty.cqrs.altcha.v1.AltchaService with CreateChallenge + VerifyChallenge RPCs. Shipped in this package as source-of-truth; Go (and other) implementations vendor a copy. - Challenge.challenge_hash is named (not "challenge") to avoid a C# property/class name collision; the MinimalApi widget JSON remaps. Runtime - AltchaGrpcVerifier maps RpcException → AltchaVerifyResult.Fail with a diagnostic reason ("verify-timeout", "service-unavailable", etc.) so the auth check surfaces a clean Unauthorized without leaking transport detail. - AltchaGrpcChallengeProvider lets create-challenge failures bubble (challenge endpoint should 5xx if altcha is down — clients retry). - AltchaGrpcOptions.TokenProvider hook for consumer-supplied HMAC service-token minting (plan-b will plug in ServiceTokenIssuer). - AddGrpcClient registered with HttpClientFactory. AddSvrntyAltchaGrpcVerifier(Action<...>) and overload binding from IConfiguration cover both wiring styles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AltchaCallCredentials.cs | 27 ++++++++ .../AltchaGrpcChallengeProvider.cs | 58 ++++++++++++++++ Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs | 31 +++++++++ Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs | 66 ++++++++++++++++++ Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto | 69 +++++++++++++++++++ .../ServiceCollectionExtensions.cs | 64 +++++++++++++++++ .../Svrnty.CQRS.Altcha.Grpc.csproj | 47 +++++++++++++ Svrnty.CQRS.sln | 14 ++++ 8 files changed, 376 insertions(+) create mode 100644 Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs create mode 100644 Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs create mode 100644 Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs create mode 100644 Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs create mode 100644 Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto create mode 100644 Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs create mode 100644 Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj diff --git a/Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs b/Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs new file mode 100644 index 0000000..e673ab8 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs @@ -0,0 +1,27 @@ +using Grpc.Core; + +namespace Svrnty.CQRS.Altcha.Grpc; + +/// +/// Helper that builds gRPC call metadata (an Authorization header) +/// from . Kept as a separate +/// shared helper so the verifier and challenge provider apply identical +/// rules. +/// +internal static class AltchaCallCredentials +{ + public static async Task BuildMetadataAsync(AltchaGrpcOptions options, CancellationToken cancellationToken) + { + if (options.TokenProvider is null) + return null; + + var token = await options.TokenProvider(cancellationToken); + if (string.IsNullOrWhiteSpace(token)) + return null; + + return new Metadata + { + { "Authorization", $"Bearer {token}" } + }; + } +} diff --git a/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs new file mode 100644 index 0000000..797a2c2 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs @@ -0,0 +1,58 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha.Grpc; + +/// +/// Default backed by gRPC. Calls +/// AltchaService.CreateChallenge on the configured endpoint and +/// projects the response onto . +/// +public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider +{ + private readonly AltchaService.AltchaServiceClient _client; + private readonly IOptions _options; + private readonly ILogger _logger; + + public AltchaGrpcChallengeProvider( + AltchaService.AltchaServiceClient client, + IOptions options, + ILogger logger) + { + _client = client; + _options = options; + _logger = logger; + } + + public async Task CreateAsync(CancellationToken cancellationToken = default) + { + var opts = _options.Value; + var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken); + var deadline = DateTime.UtcNow.Add(opts.CallTimeout); + + try + { + var response = await _client.CreateChallengeAsync( + new CreateChallengeRequest(), + headers: metadata, + deadline: deadline, + cancellationToken: cancellationToken); + + return new AltchaChallenge + { + Algorithm = response.Algorithm, + Challenge = response.ChallengeHash, + Salt = response.Salt, + Signature = response.Signature, + MaxNumber = response.Maxnumber + }; + } + catch (RpcException ex) + { + _logger.LogError(ex, "Altcha create-challenge failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode); + throw; + } + } +} diff --git a/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs new file mode 100644 index 0000000..1b9d342 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs @@ -0,0 +1,31 @@ +namespace Svrnty.CQRS.Altcha.Grpc; + +/// +/// Configuration for and +/// . Bind from configuration +/// (e.g. "Altcha" section) or pass via the registration delegate. +/// +public sealed class AltchaGrpcOptions +{ + /// + /// gRPC endpoint of the altcha service. Typically the internal + /// docker / k8s address — e.g. http://altcha:9090 or + /// https://altcha.planb.svc.cluster.local:9090. + /// + public string Endpoint { get; set; } = string.Empty; + + /// + /// Optional per-call HMAC service-token provider. When set, the + /// returned string is sent as Authorization: Bearer <token> + /// on every outbound gRPC call. Use this to integrate with whatever + /// service-auth scheme the rest of the deployment uses (e.g. plan-b's + /// ServiceTokenIssuer.GetToken("altcha")). + /// + public Func>? TokenProvider { get; set; } + + /// + /// Per-call timeout for both CreateChallenge and + /// VerifyChallenge. Defaults to 5s. + /// + public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5); +} diff --git a/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs new file mode 100644 index 0000000..5fc62d1 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs @@ -0,0 +1,66 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha.Grpc; + +/// +/// Default backed by gRPC. Calls +/// AltchaService.VerifyChallenge on the configured endpoint and +/// maps any failure (transport error, deadline, server-reported failure) +/// to . Verification failures are +/// safe defaults — callers see an Unauthorized outcome from the +/// auth check. +/// +public sealed class AltchaGrpcVerifier : IAltchaVerifier +{ + private readonly AltchaService.AltchaServiceClient _client; + private readonly IOptions _options; + private readonly ILogger _logger; + + public AltchaGrpcVerifier( + AltchaService.AltchaServiceClient client, + IOptions options, + ILogger logger) + { + _client = client; + _options = options; + _logger = logger; + } + + public async Task VerifyAsync(string payload, CancellationToken cancellationToken = default) + { + var opts = _options.Value; + try + { + var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken); + var deadline = DateTime.UtcNow.Add(opts.CallTimeout); + + var response = await _client.VerifyChallengeAsync( + new VerifyChallengeRequest { Payload = payload }, + headers: metadata, + deadline: deadline, + cancellationToken: cancellationToken); + + return response.Ok + ? AltchaVerifyResult.Success + : AltchaVerifyResult.Fail(string.IsNullOrEmpty(response.Reason) ? "invalid" : response.Reason); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded) + { + _logger.LogWarning(ex, "Altcha verify timed out against {Endpoint}.", opts.Endpoint); + return AltchaVerifyResult.Fail("verify-timeout"); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable) + { + _logger.LogWarning(ex, "Altcha service unavailable at {Endpoint}.", opts.Endpoint); + return AltchaVerifyResult.Fail("service-unavailable"); + } + catch (RpcException ex) + { + _logger.LogWarning(ex, "Altcha verify failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode); + return AltchaVerifyResult.Fail("rpc-error"); + } + } +} diff --git a/Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto b/Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto new file mode 100644 index 0000000..678c490 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package svrnty.cqrs.altcha.v1; + +option go_package = "svrnty.cqrs.altcha.v1;altchapb"; +option csharp_namespace = "Svrnty.CQRS.Altcha.Grpc"; + +// AltchaService is the wire contract between any backend that gates +// commands/queries with the [Altcha] attribute and a self-hosted Altcha +// implementation. The default .NET client lives in this package; a +// reference Go server lives in plan-b's projects/altcha (which vendors +// this proto). Keep this file the source of truth for both sides. +service AltchaService { + // Mint a fresh challenge for the widget to solve. Server-stateless: + // the response embeds an HMAC signature that VerifyChallenge re-checks. + rpc CreateChallenge(CreateChallengeRequest) returns (Challenge); + + // Verify a widget-produced solution payload. The server checks + // signature + expiry + PoW correctness, then atomically claims the + // challenge in a replay-protection cache (e.g. Redis SETNX) so each + // solution is single-use across all server replicas. + rpc VerifyChallenge(VerifyChallengeRequest) returns (VerifyChallengeResponse); +} + +message CreateChallengeRequest { + // Optional per-request complexity override (PoW search-space upper + // bound). Higher = slower client solve. Server clamps to its + // configured min/max. Omit to use the server default. + optional uint32 complexity = 1; +} + +message Challenge { + // Hashing algorithm — e.g. "SHA-256" (v2 default). Future versions may + // upgrade to PBKDF2 / Argon2; the field lets the widget pick the + // right solver. + string algorithm = 1; + + // Hex-encoded hash the client must find a preimage for. Field is + // named "challenge_hash" rather than "challenge" to avoid a C# + // property/class name collision (the generated message class is + // also named Challenge); JSON projection for the widget remaps it. + string challenge_hash = 2; + + // Hex-encoded random salt. Typically embeds the expiry timestamp so + // verifiers can re-derive the TTL without server state. + string salt = 3; + + // HMAC-SHA256(secret, algorithm|challenge|salt|maxnumber). Lets + // VerifyChallenge confirm the challenge was issued by this server. + string signature = 4; + + // PoW search-space upper bound. Equals the complexity used. + uint32 maxnumber = 5; +} + +message VerifyChallengeRequest { + // Base64-encoded JSON payload produced by the Altcha widget — the + // value carried over the wire on IHasAltchaSolution.AltchaSolution. + string payload = 1; +} + +message VerifyChallengeResponse { + bool ok = 1; + + // Diagnostic only; not surfaced to end users. Suggested values: + // "signature-invalid", "expired", "pow-incorrect", "replayed", + // "redis-unreachable", "malformed". + string reason = 2; +} diff --git a/Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs b/Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c50f6d4 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha.Grpc; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers the gRPC-backed and + /// . Configure the endpoint + /// and optional service-auth token provider via the + /// delegate. + /// + /// + /// + /// services.AddSvrntyAltcha(); + /// services.AddSvrntyAltchaGrpcVerifier(opts => + /// { + /// opts.Endpoint = "http://altcha:9090"; + /// opts.TokenProvider = async ct => await tokenIssuer.GetTokenAsync("altcha", ct); + /// }); + /// + /// + public static IServiceCollection AddSvrntyAltchaGrpcVerifier( + this IServiceCollection services, + Action configure) + { + services.Configure(configure); + RegisterCore(services); + return services; + } + + /// + /// Binds from a configuration section + /// (typically "Altcha:Grpc") and registers the gRPC verifier + /// and challenge provider. + /// + public static IServiceCollection AddSvrntyAltchaGrpcVerifier( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(configuration); + RegisterCore(services); + return services; + } + + private static void RegisterCore(IServiceCollection services) + { + services.AddGrpcClient((sp, client) => + { + var opts = sp.GetRequiredService>().Value; + if (string.IsNullOrWhiteSpace(opts.Endpoint)) + throw new InvalidOperationException( + "Altcha gRPC endpoint not configured. Set AltchaGrpcOptions.Endpoint " + + "(e.g. http://altcha:9090)."); + client.Address = new Uri(opts.Endpoint); + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + } +} diff --git a/Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj b/Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj new file mode 100644 index 0000000..3a900b8 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj @@ -0,0 +1,47 @@ + + + net10.0 + false + 14 + enable + enable + + Svrnty + David Lebee, Mathias Beaulieu-Duncan + icon.png + README.md + https://git.openharbor.io/svrnty/dotnet-cqrs + git + true + MIT + + portable + true + true + true + snupkg + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/Svrnty.CQRS.sln b/Svrnty.CQRS.sln index 9212eca..4b753ab 100644 --- a/Svrnty.CQRS.sln +++ b/Svrnty.CQRS.sln @@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstract EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Grpc", "Svrnty.CQRS.Altcha.Grpc\Svrnty.CQRS.Altcha.Grpc.csproj", "{628DE10C-FCDB-418B-8341-FA246BBCF70E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -285,6 +287,18 @@ Global {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.Build.0 = Release|Any CPU {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.ActiveCfg = Release|Any CPU {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.Build.0 = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.ActiveCfg = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.Build.0 = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.ActiveCfg = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.Build.0 = Debug|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.Build.0 = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.ActiveCfg = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.Build.0 = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.ActiveCfg = Release|Any CPU + {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE