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