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