diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/DependencyInjection/SecretsGrpcHubServerExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/DependencyInjection/SecretsGrpcHubServerExtensions.cs new file mode 100644 index 0000000..f7adba7 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/DependencyInjection/SecretsGrpcHubServerExtensions.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection; + +/// +/// Dependency-injection and endpoint wiring for hosting the central secrets hub. +/// +/// +/// +/// Register this on the central node only. A site node registers the client half instead — +/// the two are separate registrations on purpose, so "central hosts, sites follow" is visible in +/// each role's composition root rather than inferred from configuration. +/// +/// +/// Every node must be configured with the same KEK. The hub serves ciphertext, so a follower +/// whose master key differs cannot decrypt what it pulls; it fails closed on resolve with a +/// kek_id mismatch, which reads like data corruption but is a deployment error. +/// +/// +public static class SecretsGrpcHubServerExtensions +{ + /// + /// Binds and validates , registers the fail-closed + /// bearer interceptor, and attaches it to . + /// + /// + /// Validation runs here, eagerly, as well as through the options pipeline: a hub with no bearer + /// token should refuse to start rather than start and refuse every follower. The interceptor's + /// deny-all is the second layer, for the case where a host constructs the options some other way. + /// The service requires an ISecretStore in the container — register the store first. + /// + /// The service collection to add to. + /// The application configuration. + /// Configuration section holding the hub options. + /// The same instance, for chaining. + /// No bearer token is configured. + public static IServiceCollection AddZbSecretsGrpcHub( + this IServiceCollection services, + IConfiguration configuration, + string sectionPath = "Secrets:GrpcHub") + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); + + IConfigurationSection section = configuration.GetSection(sectionPath); + services.Configure(section); + + // SectionPath is internal, so binding cannot set it; stamping it here is what lets the + // startup failure and the runtime warning both name the key this host actually reads. + services.PostConfigure(options => options.SectionPath = sectionPath); + + var eager = new SecretsGrpcHubServerOptions { SectionPath = sectionPath }; + section.Bind(eager); + eager.SectionPath = sectionPath; + eager.Validate(); + + // Singleton so the "unconfigured" warning is emitted once per process rather than once per + // interceptor instance; the gRPC interceptor activator prefers a registered instance. + services.TryAddSingleton(); + + // AddGrpc is safe to call again if the host already called it — it registers with TryAdd. + services.AddGrpc() + .AddServiceOptions( + options => options.Interceptors.Add()); + + return services; + } + + /// + /// Maps the secrets hub gRPC endpoint. The host must have set up routing; + /// supplies the gRPC services and the auth interceptor. + /// + /// The endpoint route builder. + /// The same instance, for chaining. + public static IEndpointRouteBuilder MapZbSecretsHub(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + endpoints.MapGrpcService(); + return endpoints; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubServerOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubServerOptions.cs new file mode 100644 index 0000000..6a7f50c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubServerOptions.cs @@ -0,0 +1,58 @@ +namespace ZB.MOM.WW.Secrets.Replicator.Grpc; + +/// +/// Configuration for the central-hosted secrets hub. Bound from a configuration section by +/// AddZbSecretsGrpcHub. +/// +/// +/// +/// The hub serves ciphertext to followers that authenticate with one shared bearer token. That +/// token is the whole of the hub's authorization: there is no per-follower identity, because a +/// follower's authority is uniform (read the manifest, read rows) and the pull-only contract gives +/// it nothing else to be authorized for. +/// +/// +/// It cannot itself be supplied as a ${secret:} reference — resolving that reference is what +/// the hub exists to make possible — so it comes from appsettings or the environment, the same way +/// the family's mesh pre-shared keys do. +/// +/// +public sealed class SecretsGrpcHubServerOptions +{ + /// The configuration section the hub is bound from unless a host overrides it. + internal const string DefaultSectionPath = "Secrets:GrpcHub"; + + /// + /// The shared bearer token a follower must present. Required — an unset token is not "open + /// access", it is a hub that refuses every call (see ). + /// + public string BearerToken { get; set; } = string.Empty; + + /// + /// The configuration section this instance was bound from, used so failure messages name the + /// key an operator actually has to set. + /// + /// + /// Deliberately : configuration binding only writes public settable + /// properties, so a stray SectionPath key in an appsettings file cannot make the hub + /// misreport which key it wants. The registration extension sets it. + /// + internal string SectionPath { get; set; } = DefaultSectionPath; + + /// + /// Throws if the options are unusable. Called by the registration extension so a hub with no + /// token fails at startup, rather than starting and rejecting every follower at runtime. + /// + /// The bearer token is missing or blank. + public void Validate() + { + if (string.IsNullOrWhiteSpace(BearerToken)) + { + // Names the key, never the value — there is no value here, and there never will be one + // in an error message from this type. + throw new InvalidOperationException( + $"The gRPC secrets hub requires a shared bearer token ({SectionPath}:BearerToken). " + + "Until it is set the hub refuses every call."); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubAuthInterceptor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubAuthInterceptor.cs new file mode 100644 index 0000000..5f92cfd --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubAuthInterceptor.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc; + +/// +/// Gates every call to the secrets hub on a shared bearer token presented as +/// Authorization: Bearer <token>. +/// +/// +/// +/// Fail closed. If no token is configured, every call is denied — an unconfigured hub is a +/// hub that serves nothing, never a hub that serves everyone. The refusal is paired with exactly one +/// warning naming the configuration key an operator has to set. +/// +/// +/// Uniform denial. Every rejection — absent header, wrong scheme, wrong token, unconfigured +/// hub — throws the identical status with the identical +/// detail. A caller learns that it was refused and nothing else; distinguishing "no token" from +/// "wrong token" would hand an attacker a free oracle. +/// +/// +/// No token material is ever logged, presented or configured, at any level. +/// +/// +public sealed class SecretsHubAuthInterceptor : Interceptor +{ + /// gRPC normalizes metadata keys to lower case, so the lookup key is lower case too. + private const string AuthorizationHeaderName = "authorization"; + + /// + /// Matched ordinally, including case. RFC 7235 makes the scheme case-insensitive, but this is a + /// machine-to-machine channel with one client implementation, so the stricter rule costs nothing + /// and removes a class of near-miss that would otherwise have to be reasoned about. + /// + private const string BearerScheme = "Bearer "; + + /// The one detail every denial carries. It says nothing about why. + private const string DenialDetail = "Unauthenticated."; + + private readonly byte[] _expectedTokenUtf8; + private readonly string _bearerTokenConfigurationKey; + private readonly ILogger _logger; + + private int _unconfiguredWarningEmitted; + + /// + /// Creates the interceptor over the hub's configured token. + /// + /// + /// This type must keep EXACTLY ONE public constructor. Grpc.AspNetCore activates an + /// interceptor through ActivatorUtilities, which cannot choose between overloads — an + /// interceptor with more than one public constructor silently stops being invoked, and the + /// failure mode is an authentication gate that quietly does not run. A test pins the count. + /// + /// The hub options carrying the shared bearer token. + /// Diagnostics sink. Never receives token material. + public SecretsHubAuthInterceptor( + IOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + + SecretsGrpcHubServerOptions value = options.Value; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _bearerTokenConfigurationKey = $"{value.SectionPath}:BearerToken"; + _expectedTokenUtf8 = string.IsNullOrWhiteSpace(value.BearerToken) + ? [] + : Encoding.UTF8.GetBytes(value.BearerToken); + } + + /// + public override Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + ArgumentNullException.ThrowIfNull(continuation); + + Authenticate(context); + return continuation(request, context); + } + + /// + public override Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation) + { + ArgumentNullException.ThrowIfNull(continuation); + + Authenticate(context); + return continuation(requestStream, context); + } + + /// + public override Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation) + { + ArgumentNullException.ThrowIfNull(continuation); + + Authenticate(context); + return continuation(request, responseStream, context); + } + + /// + public override Task DuplexStreamingServerHandler( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context, + DuplexStreamingServerMethod continuation) + { + ArgumentNullException.ThrowIfNull(continuation); + + Authenticate(context); + return continuation(requestStream, responseStream, context); + } + + /// + /// Admits the call or throws the one denial. The streaming overloads are gated as well as the + /// unary ones even though the contract is unary-only today, so a future RPC of any shape cannot + /// arrive unauthenticated by omission. + /// + private void Authenticate(ServerCallContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (_expectedTokenUtf8.Length == 0) + { + WarnUnconfiguredOnce(); + throw Denied(); + } + + Metadata headers = context.RequestHeaders; + + // More than one Authorization header is a smuggling shape, not a legitimate request: taking + // "the first one" would let a caller present a second value that some other hop honours. + int authorizationHeaderCount = 0; + string? presented = null; + foreach (Metadata.Entry entry in headers) + { + if (!string.Equals(entry.Key, AuthorizationHeaderName, StringComparison.Ordinal)) + { + continue; + } + + authorizationHeaderCount++; + presented ??= entry.Value; + } + + if (authorizationHeaderCount != 1 || presented is null) + { + throw Denied(); + } + + if (!presented.StartsWith(BearerScheme, StringComparison.Ordinal)) + { + throw Denied(); + } + + // FixedTimeEquals compares in time independent of the contents, and returns false for a + // length mismatch without comparing at all — so the only thing a caller can time is the + // length of its own token. + byte[] presentedTokenUtf8 = Encoding.UTF8.GetBytes(presented[BearerScheme.Length..]); + if (!CryptographicOperations.FixedTimeEquals(presentedTokenUtf8, _expectedTokenUtf8)) + { + throw Denied(); + } + } + + /// + /// Builds the single denial every failure path throws. A fresh exception each time (the stack + /// trace is per-call) carrying an identical status. + /// + private static RpcException Denied() => new(new Status(StatusCode.Unauthenticated, DenialDetail)); + + /// + /// Warns once — not once per call — that the hub cannot serve, naming the exact key to set. A + /// per-call warning would bury the one line that matters under the flood it describes. + /// + private void WarnUnconfiguredOnce() + { + if (Interlocked.Exchange(ref _unconfiguredWarningEmitted, 1) != 0) + { + return; + } + + _logger.LogWarning( + "The gRPC secrets hub has no bearer token configured ({BearerTokenConfigurationKey}); " + + "every call is being refused until it is set.", + _bearerTokenConfigurationKey); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubService.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubService.cs new file mode 100644 index 0000000..4eb28b4 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsHubService.cs @@ -0,0 +1,131 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Replicator.Grpc.V1; +using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc; + +/// +/// The central-hosted secrets hub: adapts the node's own onto the +/// secrets_hub.v1 contract so site nodes can pull from it. +/// +/// +/// +/// Read-only by construction. It touches exactly two store members — +/// and — mirroring +/// the read half of ISecretReplicationHub. The proto exposes no write RPC, so there is no +/// path by which a follower can put a row into central, whatever its credentials allow. +/// +/// +/// Everything it returns is ciphertext or metadata about ciphertext. The KEK never travels: a row +/// carries the KEK's identifier and the DEK wrapped by it, and every node in a replicating +/// deployment is configured with the same KEK out of band. +/// +/// +public sealed class SecretsHubService : SecretsHub.SecretsHubBase +{ + private readonly ISecretStore _store; + private readonly ILogger _logger; + + /// Creates the hub service over the node's secret store. + /// The store to serve. Only its read members are used. + /// Diagnostics sink. + public SecretsHubService(ISecretStore store, ILogger logger) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Returns the hub's full manifest, tombstones included — a follower converges on a + /// delete by seeing the tombstone, so omitting them would make deletes unpropagatable. + /// + /// The (empty) manifest request. + /// The call context. + /// Every manifest entry the store holds. + public override async Task GetManifest( + GetManifestRequest request, ServerCallContext context) + { + ArgumentNullException.ThrowIfNull(context); + + IReadOnlyList entries = + await _store.GetManifestAsync(context.CancellationToken).ConfigureAwait(false); + + var response = new GetManifestResponse(); + foreach (SecretManifestEntry entry in entries) + { + response.Entries.Add(DomainToWire.ToWire(entry)); + } + + _logger.LogDebug("Secrets hub served a manifest of {EntryCount} entries.", response.Entries.Count); + return response; + } + + /// + /// Fetches the named encrypted rows in bulk. + /// + /// + /// A requested name the hub does not hold is omitted from the response, and an unparseable + /// name is skipped — neither is an error. Both describe the same ordinary situation: the + /// follower is acting on a manifest that was true a moment ago. Failing the whole call would turn + /// a routine race into a stalled sweep, and would let one bad name deny the follower every other + /// row in the batch. + /// + /// The names to fetch. + /// The call context. + /// One row per distinct name the hub holds. + public override async Task GetSecrets( + GetSecretsRequest request, ServerCallContext context) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(context); + + var response = new GetSecretsResponse(); + var seen = new HashSet(StringComparer.Ordinal); + int skipped = 0; + + foreach (string requested in request.Names) + { + SecretName name; + try + { + // Re-validated server-side: the wire type is a plain string, so this is where a + // path-traversing or otherwise malformed name is stopped — before it reaches a store + // that may treat the name as a path segment. + name = new SecretName(requested); + } + catch (ArgumentException) + { + skipped++; + continue; + } + + // Normalization happens in the SecretName constructor, so de-duplication has to key off + // the normalized value — otherwise "Db/Central" and "db/central" would each cost a read + // and each return the same row. + if (!seen.Add(name.Value)) + { + continue; + } + + StoredSecret? row = await _store.GetAsync(name, context.CancellationToken).ConfigureAwait(false); + if (row is null) + { + continue; + } + + response.Secrets.Add(DomainToWire.ToWire(row)); + } + + if (skipped > 0) + { + // The count, never the values: the names are caller-supplied text and this is a log. + _logger.LogWarning( + "Secrets hub skipped {SkippedCount} unparseable name(s) in a GetSecrets request.", skipped); + } + + _logger.LogDebug("Secrets hub served {RowCount} row(s).", response.Secrets.Count); + return response; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/Wire/DomainToWire.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/Wire/DomainToWire.cs new file mode 100644 index 0000000..26ad434 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/Wire/DomainToWire.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using Google.Protobuf; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Replicator.Grpc.V1; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Wire; + +/// +/// Projects the domain types onto the secrets_hub.v1 wire messages — the hub's outbound +/// half. The inbound half (wire → domain) lives in WireToDomain. +/// +/// +/// +/// The mapping is a deliberate mirror of the Akka replicator's SecretRowDto.FromStoredSecret +/// and follows the same two rules, for the same reasons. Timestamps use the round-trip "O" +/// format so a 's offset and 100 ns ticks survive intact — last-writer-wins +/// compares these for equality, so "close" is wrong. Content type travels as the enum MEMBER NAME, +/// so an unrecognized value is rejected at the far boundary instead of silently decoding to the +/// zero member. +/// +/// +/// Nullable .NET fields map onto proto3 explicit-presence fields and are left absent when +/// null rather than written as "", so null and empty string stay distinguishable end to end. +/// +/// +internal static class DomainToWire +{ + /// The round-trip ("O") format specifier used for every timestamp on this wire. + private const string RoundTripFormat = "O"; + + /// Projects a manifest entry onto its wire message. + /// The manifest entry to project. + /// The wire message. + public static ManifestEntry ToWire(SecretManifestEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return new ManifestEntry + { + Name = entry.Name.Value, + Revision = entry.Revision, + UpdatedUtc = Format(entry.UpdatedUtc), + IsDeleted = entry.IsDeleted, + }; + } + + /// Projects an encrypted row onto its wire message. + /// The encrypted row to project. + /// The wire message — ciphertext and metadata about ciphertext, never key material. + public static SecretRow ToWire(StoredSecret row) + { + ArgumentNullException.ThrowIfNull(row); + + var wire = new SecretRow + { + Name = row.Name.Value, + ContentType = row.ContentType.ToString(), + Ciphertext = ByteString.CopyFrom(row.Ciphertext), + Nonce = ByteString.CopyFrom(row.Nonce), + Tag = ByteString.CopyFrom(row.Tag), + WrappedDek = ByteString.CopyFrom(row.WrappedDek), + WrapNonce = ByteString.CopyFrom(row.WrapNonce), + WrapTag = ByteString.CopyFrom(row.WrapTag), + KekId = row.KekId, + Revision = row.Revision, + IsDeleted = row.IsDeleted, + CreatedUtc = Format(row.CreatedUtc), + UpdatedUtc = Format(row.UpdatedUtc), + }; + + // Assigned only when present: a proto3 optional string field rejects null, and writing "" + // instead would erase the distinction between "no description" and "an empty one". + if (row.Description is not null) + { + wire.Description = row.Description; + } + + if (row.DeletedUtc is { } deleted) + { + wire.DeletedUtc = Format(deleted); + } + + if (row.CreatedBy is not null) + { + wire.CreatedBy = row.CreatedBy; + } + + if (row.UpdatedBy is not null) + { + wire.UpdatedBy = row.UpdatedBy; + } + + return wire; + } + + private static string Format(DateTimeOffset value) => + value.ToString(RoundTripFormat, CultureInfo.InvariantCulture); +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubServerOptionsTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubServerOptionsTests.cs new file mode 100644 index 0000000..7eb69c2 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubServerOptionsTests.cs @@ -0,0 +1,74 @@ +namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; + +/// +/// Registration-time validation for the hub server. A misconfigured hub must fail at startup with +/// a message naming the exact configuration key, not serve unauthenticated at runtime. +/// +public sealed class SecretsGrpcHubServerOptionsTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\n")] + public void Validate_throws_when_the_bearer_token_is_unset(string token) + { + var options = new SecretsGrpcHubServerOptions { BearerToken = token }; + + InvalidOperationException error = + Assert.Throws(() => options.Validate()); + + Assert.Contains("Secrets:GrpcHub:BearerToken", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_names_the_section_path_the_hub_was_registered_under() + { + var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty }; + options.SectionPath = "Central:Secrets:Hub"; + + InvalidOperationException error = + Assert.Throws(() => options.Validate()); + + Assert.Contains("Central:Secrets:Hub:BearerToken", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_accepts_a_configured_token() + { + var options = new SecretsGrpcHubServerOptions { BearerToken = "a-configured-hub-token" }; + + options.Validate(); + } + + [Fact] + public void Validate_never_echoes_the_configured_token() + { + // A token that is present but only whitespace-padded is still a real credential; the + // failure message must never carry it. + const string token = " "; + var options = new SecretsGrpcHubServerOptions { BearerToken = token }; + + InvalidOperationException error = + Assert.Throws(() => options.Validate()); + + Assert.DoesNotContain($"'{token}'", error.Message, StringComparison.Ordinal); + Assert.DoesNotContain("was ", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Bearer_token_defaults_to_empty_so_an_unconfigured_hub_cannot_serve() + { + var options = new SecretsGrpcHubServerOptions(); + + Assert.Equal(string.Empty, options.BearerToken); + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void Section_path_defaults_to_the_documented_convention() + { + var options = new SecretsGrpcHubServerOptions(); + + Assert.Equal("Secrets:GrpcHub", options.SectionPath); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubAuthInterceptorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubAuthInterceptorTests.cs new file mode 100644 index 0000000..a3c1359 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubAuthInterceptorTests.cs @@ -0,0 +1,320 @@ +using System.Reflection; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Secrets.Replicator.Grpc.V1; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; + +/// +/// Covers the hub's authentication boundary. Every case here is a security invariant, not a +/// behaviour preference: fail-closed on an unset key, uniform denial that never says why, +/// and no token material in any log line. +/// +public sealed class SecretsHubAuthInterceptorTests +{ + private const string ConfiguredToken = "s3cr3t-hub-token-value"; + + [Fact] + public async Task Valid_bearer_token_reaches_the_continuation() + { + SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _); + var context = HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}"); + bool invoked = false; + + GetManifestResponse response = await interceptor.UnaryServerHandler( + new GetManifestRequest(), + context, + (_, _) => + { + invoked = true; + return Task.FromResult(new GetManifestResponse()); + }); + + Assert.True(invoked); + Assert.NotNull(response); + } + + [Theory] + [InlineData("Bearer wrong-token-value")] + [InlineData("Bearer s3cr3t-hub-token-valu")] // one char short — length mismatch + [InlineData("Bearer s3cr3t-hub-token-values")] // one char long + [InlineData("Bearer S3CR3T-HUB-TOKEN-VALUE")] // case must matter + public async Task Wrong_token_is_denied(string headerValue) + { + await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue); + } + + [Fact] + public async Task Missing_authorization_header_is_denied() + { + await AssertDeniedAsync(Build(ConfiguredToken, out _), header: null); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(ConfiguredToken)] // bare token, no scheme + [InlineData("Basic " + ConfiguredToken)] // wrong scheme + [InlineData("Bearer")] // scheme only + [InlineData("Bearer ")] // scheme, empty token + [InlineData("bearer " + ConfiguredToken)] // scheme case + [InlineData("Bearer " + ConfiguredToken)] // extra space is part of the token + public async Task Malformed_authorization_header_is_denied(string headerValue) + { + await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue); + } + + [Fact] + public async Task Unset_configured_token_denies_a_valid_looking_request() + { + // Fail closed: with no key configured there is no credential that can be correct, so a + // well-formed bearer request is refused exactly like a malformed one. + await AssertDeniedAsync(Build(string.Empty, out _), $"Bearer {ConfiguredToken}"); + } + + [Fact] + public async Task Unset_configured_token_warns_once_naming_the_exact_config_key() + { + SecretsHubAuthInterceptor interceptor = Build(string.Empty, out HubCapturingLogger logger); + + for (int i = 0; i < 3; i++) + { + await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}"); + } + + List warnings = logger.Entries + .Where(e => e.Level == LogLevel.Warning) + .Select(e => e.Message) + .ToList(); + + string warning = Assert.Single(warnings); + Assert.Contains("Secrets:GrpcHub:BearerToken", warning, StringComparison.Ordinal); + } + + [Fact] + public async Task Unset_token_warning_names_the_section_path_it_was_registered_under() + { + var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty }; + options.SectionPath = "Central:Secrets:Hub"; + var logger = new HubCapturingLogger(); + var interceptor = new SecretsHubAuthInterceptor(Options.Create(options), logger); + + await AssertDeniedAsync(interceptor, "Bearer anything"); + + Assert.Contains( + logger.Entries, + e => e.Level == LogLevel.Warning + && e.Message.Contains("Central:Secrets:Hub:BearerToken", StringComparison.Ordinal)); + } + + [Fact] + public async Task Every_failure_mode_produces_the_identical_denial() + { + SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out _); + SecretsHubAuthInterceptor unset = Build(string.Empty, out _); + + var details = new List + { + (await AssertDeniedAsync(configured, header: null)).Status.Detail, + (await AssertDeniedAsync(configured, "")).Status.Detail, + (await AssertDeniedAsync(configured, "Basic abc")).Status.Detail, + (await AssertDeniedAsync(configured, "Bearer ")).Status.Detail, + (await AssertDeniedAsync(configured, "Bearer wrong-token-value")).Status.Detail, + (await AssertDeniedAsync(unset, $"Bearer {ConfiguredToken}")).Status.Detail, + }; + + Assert.Single(details.Distinct(StringComparer.Ordinal)); + + // And it must not narrate the reason to the caller. + foreach (string detail in details) + { + foreach (string leak in new[] { "expired", "missing", "malformed", "unset", "no token", "wrong" }) + { + Assert.DoesNotContain(leak, detail, StringComparison.OrdinalIgnoreCase); + } + } + } + + [Fact] + public async Task Denied_call_never_reaches_the_continuation() + { + SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _); + bool invoked = false; + + await Assert.ThrowsAsync(() => interceptor.UnaryServerHandler( + new GetManifestRequest(), + HubServerCallContextStub.WithAuthorization("Bearer nope"), + (_, _) => + { + invoked = true; + return Task.FromResult(new GetManifestResponse()); + })); + + Assert.False(invoked); + } + + [Fact] + public async Task Non_unary_calls_are_gated_too() + { + SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _); + bool invoked = false; + + RpcException error = await Assert.ThrowsAsync( + () => interceptor.ServerStreamingServerHandler( + new GetSecretsRequest(), + new HubServerStreamWriterStub(), + HubServerCallContextStub.WithAuthorization(header: null), + (_, _, _) => + { + invoked = true; + return Task.CompletedTask; + })); + + Assert.Equal(StatusCode.Unauthenticated, error.StatusCode); + Assert.False(invoked); + } + + [Fact] + public async Task No_log_line_ever_contains_token_material() + { + const string presented = "presented-token-that-must-not-be-logged"; + SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out HubCapturingLogger configuredLog); + SecretsHubAuthInterceptor unset = Build(string.Empty, out HubCapturingLogger unsetLog); + + await AssertDeniedAsync(configured, $"Bearer {presented}"); + await AssertDeniedAsync(configured, header: null); + await configured.UnaryServerHandler( + new GetManifestRequest(), + HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}"), + (_, _) => Task.FromResult(new GetManifestResponse())); + await AssertDeniedAsync(unset, $"Bearer {presented}"); + + foreach (string message in configuredLog.Entries.Concat(unsetLog.Entries).Select(e => e.Message)) + { + Assert.DoesNotContain(ConfiguredToken, message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(presented, message, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void Interceptor_has_exactly_one_public_constructor() + { + // Grpc.AspNetCore's interceptor activator silently stops invoking an interceptor whose + // constructor is ambiguous — the failure mode is an UNAUTHENTICATED gate that quietly + // stops running, so this is pinned rather than left to review. + ConstructorInfo[] ctors = typeof(SecretsHubAuthInterceptor) + .GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + Assert.Single(ctors); + } + + private static SecretsHubAuthInterceptor Build(string token, out HubCapturingLogger logger) + { + logger = new HubCapturingLogger(); + var options = new SecretsGrpcHubServerOptions { BearerToken = token }; + return new SecretsHubAuthInterceptor(Options.Create(options), logger); + } + + private static async Task AssertDeniedAsync( + SecretsHubAuthInterceptor interceptor, string? header) + { + RpcException error = await Assert.ThrowsAsync( + () => interceptor.UnaryServerHandler( + new GetManifestRequest(), + HubServerCallContextStub.WithAuthorization(header), + (_, _) => Task.FromResult(new GetManifestResponse()))); + + Assert.Equal(StatusCode.Unauthenticated, error.StatusCode); + return error; + } +} + +/// +/// A minimal for exercising interceptors and service methods +/// directly. Hand-rolled because Grpc.Core.Testing.TestServerCallContext ships in a +/// package this project deliberately does not reference. +/// +internal sealed class HubServerCallContextStub : ServerCallContext +{ + private readonly Metadata _requestHeaders; + private readonly Metadata _responseTrailers = new(); + private readonly AuthContext _authContext = new(null, new Dictionary>()); + private readonly Dictionary _userState = new(); + + private HubServerCallContextStub(Metadata requestHeaders) => _requestHeaders = requestHeaders; + + /// Builds a context carrying the given Authorization header, or none when null. + public static HubServerCallContextStub WithAuthorization(string? header) + { + var headers = new Metadata(); + if (header is not null) + { + headers.Add("Authorization", header); + } + + return new HubServerCallContextStub(headers); + } + + /// Builds a context with no metadata at all, for service-level tests. + public static HubServerCallContextStub Empty() => new(new Metadata()); + + protected override string MethodCore => "/zb.mom.ww.secrets.hub.v1.SecretsHub/Test"; + protected override string HostCore => "localhost"; + protected override string PeerCore => "ipv4:127.0.0.1:0"; + protected override DateTime DeadlineCore => DateTime.UtcNow.AddSeconds(30); + protected override Metadata RequestHeadersCore => _requestHeaders; + protected override CancellationToken CancellationTokenCore => CancellationToken.None; + protected override Metadata ResponseTrailersCore => _responseTrailers; + protected override Status StatusCore { get; set; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore => _authContext; + protected override IDictionary UserStateCore => _userState; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException("Context propagation is not used by the secrets hub."); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; +} + +/// A no-op response stream, enough to drive the server-streaming interceptor path. +internal sealed class HubServerStreamWriterStub : IServerStreamWriter +{ + /// Everything written, in order. + public List Written { get; } = []; + + /// + public WriteOptions? WriteOptions { get; set; } + + /// + public Task WriteAsync(T message) + { + Written.Add(message); + return Task.CompletedTask; + } +} + +/// Captures formatted log messages so tests can assert on what was — and was not — logged. +internal sealed class HubCapturingLogger : ILogger +{ + /// Every entry logged, formatted exactly as a sink would render it. + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + Entries.Add((logLevel, formatter(state, exception))); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubServiceTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubServiceTests.cs new file mode 100644 index 0000000..786349a --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubServiceTests.cs @@ -0,0 +1,322 @@ +using System.Globalization; +using Google.Protobuf; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Replicator.Grpc.V1; +using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire; + +namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; + +/// +/// Covers the read-only hub service and the domain→wire projection behind it. The contract the +/// proto states is binding here: a name the hub does not hold is omitted, never an error, and +/// every field crosses losslessly. +/// +public sealed class SecretsHubServiceTests +{ + [Fact] + public async Task GetManifest_returns_every_entry_including_tombstones() + { + var store = new HubFakeSecretStore(); + store.Seed(Row("alpha")); + store.Seed(Row("beta", isDeleted: true)); + + SecretsHubService service = Service(store); + + GetManifestResponse response = + await service.GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty()); + + Assert.Equal(["alpha", "beta"], response.Entries.Select(e => e.Name).Order()); + ManifestEntry tombstone = response.Entries.Single(e => e.Name == "beta"); + Assert.True(tombstone.IsDeleted); + } + + [Fact] + public async Task GetManifest_maps_every_manifest_field() + { + var updated = new DateTimeOffset(2026, 8, 7, 13, 45, 12, 345, TimeSpan.FromHours(-5)) + .AddTicks(6789); + var store = new HubFakeSecretStore(); + store.SeedManifest(new SecretManifestEntry + { + Name = new SecretName("db/central"), + Revision = 42, + UpdatedUtc = updated, + IsDeleted = true, + }); + + GetManifestResponse response = await Service(store) + .GetManifest(new GetManifestRequest(), HubServerCallContextStub.Empty()); + + ManifestEntry entry = Assert.Single(response.Entries); + Assert.Equal("db/central", entry.Name); + Assert.Equal(42, entry.Revision); + Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), entry.UpdatedUtc); + Assert.True(entry.IsDeleted); + } + + [Fact] + public async Task GetSecrets_omits_names_the_hub_does_not_hold() + { + var store = new HubFakeSecretStore(); + store.Seed(Row("present")); + + var request = new GetSecretsRequest { Names = { "present", "absent" } }; + + GetSecretsResponse response = + await Service(store).GetSecrets(request, HubServerCallContextStub.Empty()); + + SecretRow row = Assert.Single(response.Secrets); + Assert.Equal("present", row.Name); + } + + [Fact] + public async Task GetSecrets_returns_tombstoned_rows() + { + // A follower needs the tombstone itself to converge on a delete — omitting it would make a + // deleted secret un-deletable downstream. + var store = new HubFakeSecretStore(); + store.Seed(Row("gone", isDeleted: true)); + + GetSecretsResponse response = await Service(store) + .GetSecrets(new GetSecretsRequest { Names = { "gone" } }, HubServerCallContextStub.Empty()); + + Assert.True(Assert.Single(response.Secrets).IsDeleted); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("../etc/passwd")] + [InlineData("/rooted")] + [InlineData("has space")] + [InlineData("double//slash")] + public async Task GetSecrets_skips_an_invalid_name_instead_of_faulting(string invalid) + { + var store = new HubFakeSecretStore(); + store.Seed(Row("present")); + + var request = new GetSecretsRequest { Names = { invalid, "present" } }; + + GetSecretsResponse response = + await Service(store).GetSecrets(request, HubServerCallContextStub.Empty()); + + Assert.Equal("present", Assert.Single(response.Secrets).Name); + } + + [Fact] + public async Task GetSecrets_normalizes_names_before_looking_them_up() + { + var store = new HubFakeSecretStore(); + store.Seed(Row("db/central")); + + GetSecretsResponse response = await Service(store).GetSecrets( + new GetSecretsRequest { Names = { " DB/Central " } }, HubServerCallContextStub.Empty()); + + Assert.Equal("db/central", Assert.Single(response.Secrets).Name); + } + + [Fact] + public async Task GetSecrets_returns_one_row_per_distinct_name() + { + var store = new HubFakeSecretStore(); + store.Seed(Row("dup")); + + GetSecretsResponse response = await Service(store).GetSecrets( + new GetSecretsRequest { Names = { "dup", "dup", "DUP" } }, HubServerCallContextStub.Empty()); + + Assert.Single(response.Secrets); + Assert.Equal(1, store.GetCallCount); + } + + [Fact] + public async Task GetSecrets_on_an_empty_request_reads_nothing() + { + var store = new HubFakeSecretStore(); + store.Seed(Row("present")); + + GetSecretsResponse response = await Service(store) + .GetSecrets(new GetSecretsRequest(), HubServerCallContextStub.Empty()); + + Assert.Empty(response.Secrets); + Assert.Equal(0, store.GetCallCount); + } + + [Fact] + public void DomainToWire_maps_a_fully_populated_row_field_by_field() + { + // Deliberately non-UTC offsets: DateTimeOffset carries an offset that a UTC-instant + // representation would silently normalize away. + var created = new DateTimeOffset(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-5)).AddTicks(9012); + var updated = new DateTimeOffset(2026, 8, 7, 9, 10, 11, 121, TimeSpan.FromHours(5.5)).AddTicks(3456); + var deleted = new DateTimeOffset(2026, 8, 7, 9, 30, 0, TimeSpan.FromHours(9)); + + var row = new StoredSecret + { + Name = new SecretName("db/central"), + Description = "central connection string", + ContentType = SecretContentType.ConnectionString, + Ciphertext = [1, 2, 3, 4], + Nonce = [5, 6, 7], + Tag = [8, 9], + WrappedDek = [10, 11, 12], + WrapNonce = [13, 14], + WrapTag = [15], + KekId = "kek-2026-08", + Revision = 7, + IsDeleted = true, + DeletedUtc = deleted, + CreatedUtc = created, + UpdatedUtc = updated, + CreatedBy = "alice", + UpdatedBy = "bob", + }; + + SecretRow wire = DomainToWire.ToWire(row); + + Assert.Equal("db/central", wire.Name); + Assert.Equal("central connection string", wire.Description); + Assert.Equal("ConnectionString", wire.ContentType); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, wire.Ciphertext.ToByteArray()); + Assert.Equal(new byte[] { 5, 6, 7 }, wire.Nonce.ToByteArray()); + Assert.Equal(new byte[] { 8, 9 }, wire.Tag.ToByteArray()); + Assert.Equal(new byte[] { 10, 11, 12 }, wire.WrappedDek.ToByteArray()); + Assert.Equal(new byte[] { 13, 14 }, wire.WrapNonce.ToByteArray()); + Assert.Equal(new byte[] { 15 }, wire.WrapTag.ToByteArray()); + Assert.Equal("kek-2026-08", wire.KekId); + Assert.Equal(7, wire.Revision); + Assert.True(wire.IsDeleted); + Assert.Equal(deleted.ToString("O", CultureInfo.InvariantCulture), wire.DeletedUtc); + Assert.Equal(created.ToString("O", CultureInfo.InvariantCulture), wire.CreatedUtc); + Assert.Equal(updated.ToString("O", CultureInfo.InvariantCulture), wire.UpdatedUtc); + Assert.Equal("alice", wire.CreatedBy); + Assert.Equal("bob", wire.UpdatedBy); + } + + [Fact] + public void DomainToWire_leaves_nullable_fields_absent_rather_than_empty() + { + StoredSecret row = Row("minimal"); + + SecretRow wire = DomainToWire.ToWire(row); + + Assert.False(wire.HasDescription); + Assert.False(wire.HasDeletedUtc); + Assert.False(wire.HasCreatedBy); + Assert.False(wire.HasUpdatedBy); + } + + [Fact] + public void DomainToWire_distinguishes_an_empty_string_from_a_null() + { + StoredSecret row = Row("empties") with { Description = string.Empty, CreatedBy = string.Empty }; + + SecretRow wire = DomainToWire.ToWire(row); + + Assert.True(wire.HasDescription); + Assert.Equal(string.Empty, wire.Description); + Assert.True(wire.HasCreatedBy); + Assert.Equal(string.Empty, wire.CreatedBy); + } + + [Theory] + [InlineData(SecretContentType.Text, "Text")] + [InlineData(SecretContentType.ConnectionString, "ConnectionString")] + [InlineData(SecretContentType.Json, "Json")] + [InlineData(SecretContentType.BinaryBase64, "BinaryBase64")] + public void DomainToWire_writes_the_content_type_member_name(SecretContentType type, string expected) + { + SecretRow wire = DomainToWire.ToWire(Row("typed") with { ContentType = type }); + + Assert.Equal(expected, wire.ContentType); + } + + private static SecretsHubService Service(ISecretStore store) => + new(store, NullLogger.Instance); + + private static StoredSecret Row(string name, bool isDeleted = false) => new() + { + Name = new SecretName(name), + ContentType = SecretContentType.Text, + Ciphertext = [1], + Nonce = [2], + Tag = [3], + WrappedDek = [4], + WrapNonce = [5], + WrapTag = [6], + KekId = "kek", + Revision = 1, + IsDeleted = isDeleted, + DeletedUtc = isDeleted ? DateTimeOffset.UnixEpoch : null, + CreatedUtc = DateTimeOffset.UnixEpoch, + UpdatedUtc = DateTimeOffset.UnixEpoch, + }; +} + +/// +/// An in-memory . Only the two read members the hub adapts are +/// implemented; the write half throws, which is itself part of the pull-only pin — a hub that ever +/// started writing would fail loudly in these tests. +/// +internal sealed class HubFakeSecretStore : ISecretStore +{ + private readonly Dictionary _rows = new(StringComparer.Ordinal); + private readonly List _manifestOverride = []; + + /// How many single-row reads the service issued. + public int GetCallCount { get; private set; } + + /// Adds a row, and the manifest entry derived from it. + public void Seed(StoredSecret row) => _rows[row.Name.Value] = row; + + /// Adds a manifest entry with no backing row, for manifest-shape assertions. + public void SeedManifest(SecretManifestEntry entry) => _manifestOverride.Add(entry); + + /// + public Task GetAsync(SecretName name, CancellationToken ct) + { + GetCallCount++; + return Task.FromResult(_rows.GetValueOrDefault(name.Value)); + } + + /// + public Task> GetManifestAsync(CancellationToken ct) + { + if (_manifestOverride.Count > 0) + { + return Task.FromResult>(_manifestOverride); + } + + IReadOnlyList entries = _rows.Values + .Select(r => new SecretManifestEntry + { + Name = r.Name, + Revision = r.Revision, + UpdatedUtc = r.UpdatedUtc, + IsDeleted = r.IsDeleted, + }) + .ToList(); + + return Task.FromResult(entries); + } + + /// + public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); + + /// + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task> ListAsync(bool includeDeleted, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task ApplyRewrapAsync( + StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => + throw new NotSupportedException(); +}