diff --git a/Svrnty.CQRS.Altcha.Abstractions/AltchaAttribute.cs b/Svrnty.CQRS.Altcha.Abstractions/AltchaAttribute.cs new file mode 100644 index 0000000..7867335 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/AltchaAttribute.cs @@ -0,0 +1,27 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Marks a command or query as requiring proof-of-work (or equivalent +/// anti-abuse evidence) before the framework will dispatch it to the handler. +/// +/// +/// The accompanying request type should implement +/// to carry the widget's solution payload. The framework's Altcha +/// authorization check (registered via AddSvrntyAltcha()) reads the +/// solution off the request and calls the configured . +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class AltchaAttribute : Attribute +{ + /// + /// When true (default), a valid mobile-attestation token on the + /// request satisfies the requirement without needing a proof-of-work + /// solution. The Altcha check reads + /// ["mobile_attested"] + /// — when stamped true by an earlier check (e.g. an Apple + /// App Attest / Play Integrity verifier), the PoW check is skipped. + /// Set to false on commands where PoW must always run regardless + /// of caller. + /// + public bool AllowMobileAttestationBypass { get; set; } = true; +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/AltchaChallenge.cs b/Svrnty.CQRS.Altcha.Abstractions/AltchaChallenge.cs new file mode 100644 index 0000000..cc3628b --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/AltchaChallenge.cs @@ -0,0 +1,28 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Server-issued Altcha challenge. Shape matches what the +/// altcha widget v3 +/// expects from its challengeurl. +/// +public sealed class AltchaChallenge +{ + /// Hashing algorithm name (e.g. SHA-256). + public required string Algorithm { get; init; } + + /// Hex-encoded hash the client must find a preimage for. + public required string Challenge { get; init; } + + /// Hex-encoded salt (embeds an expiry timestamp). + public required string Salt { get; init; } + + /// + /// HMAC-SHA256 of algorithm|challenge|salt|maxnumber using the + /// server's signing secret. Lets the verify step trust the issued + /// challenge without keeping per-challenge state. + /// + public required string Signature { get; init; } + + /// Upper bound of the PoW search space (equals the requested complexity). + public required uint MaxNumber { get; init; } +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/AltchaVerifyResult.cs b/Svrnty.CQRS.Altcha.Abstractions/AltchaVerifyResult.cs new file mode 100644 index 0000000..70cad68 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/AltchaVerifyResult.cs @@ -0,0 +1,20 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Outcome of an Altcha solution verification. +/// +public sealed class AltchaVerifyResult +{ + public required bool Ok { get; init; } + + /// + /// Diagnostic only — not surfaced to end users. Suggested values: + /// signature-invalid, expired, pow-incorrect, + /// replayed, redis-unreachable, malformed. + /// + public string? Reason { get; init; } + + public static AltchaVerifyResult Success { get; } = new() { Ok = true }; + + public static AltchaVerifyResult Fail(string reason) => new() { Ok = false, Reason = reason }; +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/IAltchaChallengeProvider.cs b/Svrnty.CQRS.Altcha.Abstractions/IAltchaChallengeProvider.cs new file mode 100644 index 0000000..bcf4e88 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/IAltchaChallengeProvider.cs @@ -0,0 +1,13 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Mints an Altcha challenge for the widget to solve. The default +/// implementation in Svrnty.CQRS.Altcha.Grpc talks to a self-hosted +/// altcha service; the Svrnty.CQRS.Altcha.MinimalApi package exposes +/// GET /api/altcha/challenge that returns this DTO as the JSON +/// shape the widget expects. +/// +public interface IAltchaChallengeProvider +{ + Task CreateAsync(CancellationToken cancellationToken = default); +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/IAltchaVerifier.cs b/Svrnty.CQRS.Altcha.Abstractions/IAltchaVerifier.cs new file mode 100644 index 0000000..59c198a --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/IAltchaVerifier.cs @@ -0,0 +1,18 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Verifies an Altcha solution payload. The default implementation in +/// Svrnty.CQRS.Altcha.Grpc talks to a self-hosted altcha service +/// over gRPC; consumers may register their own implementation (e.g. an +/// in-process variant or a different transport) and the +/// -based pipeline picks it up +/// automatically. +/// +public interface IAltchaVerifier +{ + /// + /// Base64-encoded JSON payload produced by the Altcha widget — the same + /// string carried on . + /// + Task VerifyAsync(string payload, CancellationToken cancellationToken = default); +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/IHasAltchaSolution.cs b/Svrnty.CQRS.Altcha.Abstractions/IHasAltchaSolution.cs new file mode 100644 index 0000000..5d98b8b --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/IHasAltchaSolution.cs @@ -0,0 +1,16 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Implemented by command and query POCOs that carry an Altcha widget +/// solution. The framework's Altcha check reads +/// off the materialized request, so the value travels naturally over HTTP +/// (JSON body field) and gRPC (proto field) without any extra plumbing. +/// +public interface IHasAltchaSolution +{ + /// + /// Base64-encoded JSON payload produced by the Altcha widget. Null / + /// empty causes the check to reject the request. + /// + string? AltchaSolution { get; set; } +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/IMobileAttestationProvider.cs b/Svrnty.CQRS.Altcha.Abstractions/IMobileAttestationProvider.cs new file mode 100644 index 0000000..c5005bc --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/IMobileAttestationProvider.cs @@ -0,0 +1,21 @@ +namespace Svrnty.CQRS.Altcha.Abstractions; + +/// +/// Phase 3 placeholder — when a future module implements Apple App Attest / +/// Google Play Integrity verification, it stamps +/// ["mobile_attested"] +/// based on the verification result, and the Altcha check reads that flag +/// to short-circuit when +/// is true. +/// +/// +/// Intentionally left abstract and unwired in this phase. The interface +/// exists so Phase 3 can drop in an implementation without touching command +/// definitions or the Altcha check. +/// +public interface IMobileAttestationProvider +{ + /// Platform-specific attestation token from the request. + /// true if attestation passes; false otherwise. + Task VerifyAsync(string attestationToken, CancellationToken cancellationToken = default); +} diff --git a/Svrnty.CQRS.Altcha.Abstractions/Svrnty.CQRS.Altcha.Abstractions.csproj b/Svrnty.CQRS.Altcha.Abstractions/Svrnty.CQRS.Altcha.Abstractions.csproj new file mode 100644 index 0000000..ccabbb3 --- /dev/null +++ b/Svrnty.CQRS.Altcha.Abstractions/Svrnty.CQRS.Altcha.Abstractions.csproj @@ -0,0 +1,33 @@ + + + net10.0 + true + 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 + + + + + + + + + + + diff --git a/Svrnty.CQRS.sln b/Svrnty.CQRS.sln index 37176b1..7ea601d 100644 --- a/Svrnty.CQRS.sln +++ b/Svrnty.CQRS.sln @@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstractions", "Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj", "{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -257,6 +259,18 @@ Global {3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU {3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU {3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.ActiveCfg = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.Build.0 = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.Build.0 = Debug|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.Build.0 = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.ActiveCfg = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.Build.0 = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = Release|Any CPU + {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE