From 07a7a683b770840897e34742416e4060bbe30437 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 12 May 2026 20:09:00 -0400 Subject: [PATCH] feat(altcha): IAltchaDifficultyAdvisor for per-request PoW complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an abstraction over the CreateChallengeRequest.complexity field (already present in the proto since the original altcha module landed), letting applications scale PoW difficulty per request based on actor signals — repeat-offender counters, threat-intel headers, reputation scores — without leaking those concerns into the gRPC provider. - new IAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha.Abstractions: Task GetComplexityAsync(...). null means "use the upstream service's configured default." - NullAltchaDifficultyAdvisor in Svrnty.CQRS.Altcha is the no-op fallback registered by AddSvrntyAltcha() via TryAddSingleton, so applications can replace it without ordering constraints. - AltchaGrpcChallengeProvider now resolves the advisor and sets CreateChallengeRequest.Complexity when the advisor returns a value. The Altcha server clamps to its configured min/max, so callers don't need to enforce bounds here. No breaking changes to existing consumers — the no-op default keeps behaviour identical when no advisor is registered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../IAltchaDifficultyAdvisor.cs | 27 +++++++++++++++++++ .../AltchaGrpcChallengeProvider.cs | 13 ++++++++- .../NullAltchaDifficultyAdvisor.cs | 15 +++++++++++ .../ServiceCollectionExtensions.cs | 22 +++++++++------ 4 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs create mode 100644 Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs diff --git a/Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs b/Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs new file mode 100644 index 0000000..e484a65 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs @@ -0,0 +1,27 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Resolves a per-request PoW complexity (search-space upper bound) for the +/// next Altcha challenge the server will mint. Implementations may consult +/// per-actor signals — repeat-offender counters, threat-intel headers, +/// reputation scores — to scale difficulty up for suspicious actors while +/// keeping the baseline cheap for everyone else. +/// +/// +/// The framework ships a no-op that +/// always returns null, meaning "use the upstream service's configured +/// default complexity." Applications opt into adaptive difficulty by +/// replacing the registration with their own implementation; consult request +/// context via +/// or scoped DI. +/// +public interface IAltchaDifficultyAdvisor +{ + /// + /// Returns the desired maxNumber (PoW search-space upper bound) + /// for the next challenge, or null to defer to the upstream + /// service default. The Altcha server clamps to its configured min/max, + /// so callers don't need to enforce bounds here. + /// + Task GetComplexityAsync(CancellationToken cancellationToken = default); +} diff --git a/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs index 797a2c2..4ecf35f 100644 --- a/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs +++ b/Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs @@ -14,15 +14,18 @@ public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider { private readonly AltchaService.AltchaServiceClient _client; private readonly IOptions _options; + private readonly IAltchaDifficultyAdvisor _advisor; private readonly ILogger _logger; public AltchaGrpcChallengeProvider( AltchaService.AltchaServiceClient client, IOptions options, + IAltchaDifficultyAdvisor advisor, ILogger logger) { _client = client; _options = options; + _advisor = advisor; _logger = logger; } @@ -32,10 +35,18 @@ public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken); var deadline = DateTime.UtcNow.Add(opts.CallTimeout); + var request = new CreateChallengeRequest(); + var advisedComplexity = await _advisor.GetComplexityAsync(cancellationToken); + if (advisedComplexity is uint complexity) + { + request.Complexity = complexity; + _logger.LogDebug("Altcha advisor requested complexity {Complexity}", complexity); + } + try { var response = await _client.CreateChallengeAsync( - new CreateChallengeRequest(), + request, headers: metadata, deadline: deadline, cancellationToken: cancellationToken); diff --git a/Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs b/Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs new file mode 100644 index 0000000..ce674b3 --- /dev/null +++ b/Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs @@ -0,0 +1,15 @@ +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha; + +/// +/// No-op fallback registered by AddSvrntyAltcha(). Always returns +/// null, leaving complexity at the upstream Altcha service's +/// configured default. Applications that want adaptive difficulty +/// replace this registration with their own implementation. +/// +internal sealed class NullAltchaDifficultyAdvisor : IAltchaDifficultyAdvisor +{ + public Task GetComplexityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(null); +} diff --git a/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs index 0bccdc2..35974b2 100644 --- a/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs +++ b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Svrnty.CQRS.Abstractions.Security; +using Svrnty.CQRS.Altcha.Abstractions; namespace Svrnty.CQRS.Altcha; @@ -9,22 +10,27 @@ public static class ServiceCollectionExtensions /// /// Registers as both an /// and an - /// . The check is a no-op until - /// an - /// implementation is also registered (typically via - /// AddSvrntyAltchaGrpcVerifier(...) from - /// Svrnty.CQRS.Altcha.Grpc). + /// , plus a no-op + /// that defers to the upstream + /// Altcha service's configured default complexity. Applications opt + /// into adaptive difficulty by registering their own + /// before or after this call — + /// the TryAdd registration here yields to any existing one. /// /// - /// Idempotent for the concrete check; the multi-instance interface - /// registrations are added unconditionally, so callers should invoke - /// this exactly once per application startup. + /// The check is a no-op until an + /// implementation is also registered + /// (typically via AddSvrntyAltchaGrpcVerifier(...) from + /// Svrnty.CQRS.Altcha.Grpc). Idempotent for the concrete check; + /// the multi-instance interface registrations are added unconditionally, + /// so callers should invoke this exactly once per application startup. /// public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services) { services.TryAddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); return services; } }