diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs index a563c524..3ea53192 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullAuditEventsClient.cs @@ -337,13 +337,26 @@ public sealed class GrpcPullAuditEventsInvoker private GrpcChannel CreateChannel(string siteId, string endpoint) => GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions { - HttpHandler = new SocketsHttpHandler - { - KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, - KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, - KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, - }, - }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); + HttpHandler = CreateHandler(), + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint)); + + /// + /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the + /// handler. Production leaves it null, so the OS trust store applies. + /// + internal Action? ConfigureHandler { get; init; } + + private SocketsHttpHandler CreateHandler() + { + var handler = new SocketsHttpHandler + { + KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, + KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + }; + ConfigureHandler?.Invoke(handler); + return handler; + } /// public void RetainOnly(IReadOnlyList liveSites) => diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs index 8aabe554..f5d83431 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullSiteCallsClient.cs @@ -354,13 +354,26 @@ public sealed class GrpcPullSiteCallsInvoker private GrpcChannel CreateChannel(string siteId, string endpoint) => GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions { - HttpHandler = new SocketsHttpHandler - { - KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, - KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, - KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, - }, - }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); + HttpHandler = CreateHandler(), + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint)); + + /// + /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the + /// handler. Production leaves it null, so the OS trust store applies. + /// + internal Action? ConfigureHandler { get; init; } + + private SocketsHttpHandler CreateHandler() + { + var handler = new SocketsHttpHandler + { + KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, + KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + }; + ConfigureHandler?.Invoke(handler); + return handler; + } /// public void RetainOnly(IReadOnlyList liveSites) => diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs index f9e0d7ac..11e4fe7b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/GrpcPullTraceEventsClient.cs @@ -216,13 +216,26 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull private GrpcChannel CreateChannel(string siteId, string endpoint) => GrpcChannel.ForAddress(endpoint, new GrpcChannelOptions { - HttpHandler = new SocketsHttpHandler - { - KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, - KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, - KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, - }, - }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId)); + HttpHandler = CreateHandler(), + }.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint)); + + /// + /// Test seam (ScadaBridge#108): lets a test trust a generated server certificate on the + /// handler. Production leaves it null, so the OS trust store applies. + /// + internal Action? ConfigureHandler { get; init; } + + private SocketsHttpHandler CreateHandler() + { + var handler = new SocketsHttpHandler + { + KeepAlivePingDelay = _options.GrpcKeepAlivePingDelay, + KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout, + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + }; + ConfigureHandler?.Invoke(handler); + return handler; + } /// public void RetainOnly(IReadOnlyList liveSites) => diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj index b50f3ae1..1073b3a0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/ZB.MOM.WW.ScadaBridge.AuditLog.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs new file mode 100644 index 00000000..83d03ecd --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/AuditLogPullTlsEndToEndTests.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.AuditLog.Central; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// The three AuditLog pull invokers over a real HTTPS gRPC listener (ScadaBridge#108): real Kestrel +/// HTTP/2 over TLS (ALPN), the real , and the production +/// invokers. Same shape as (ScadaBridge#64). Before the +/// invokers passed their endpoint to WithSiteCredentials they composed the PSK over insecure +/// credentials and threw at channel construction for any https site address. +/// +public sealed class AuditLogPullTlsEndToEndTests : IAsyncLifetime +{ + private const string SiteKey = "the-site-a-preshared-key"; + + private X509Certificate2 _certificate = null!; + private IHost _host = null!; + private string _address = null!; + + /// Generates a localhost certificate and starts a TLS gRPC host with the real interceptor. + /// A task that represents the asynchronous operation. + public async Task InitializeAsync() + { + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + request.CertificateExtensions.Add(san.Build()); + using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); + _certificate = X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), password: null); + } + + _host = await new HostBuilder() + .ConfigureWebHost(web => web + .UseKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, 0, listen => + { + listen.Protocols = HttpProtocols.Http2; + listen.UseHttps(_certificate); + })) + .ConfigureServices(services => + { + services.AddGrpc(o => o.Interceptors.Add()); + services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey })); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(e => e.MapGrpcService()); + })) + .StartAsync(); + + var bound = _host.Services.GetRequiredService().Features.Get()!.Addresses.Single(); + _address = $"https://localhost:{new Uri(bound).Port}"; + } + + /// Stops the host and releases the certificate. + /// A task that represents the asynchronous operation. + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + _certificate.Dispose(); + } + + private void Trust(SocketsHttpHandler handler) => + handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) => + cert is not null && string.Equals(cert.GetCertHashString(), _certificate.Thumbprint, StringComparison.OrdinalIgnoreCase); + + /// Verifies the audit-events pull succeeds over TLS with the right key. + /// A task that represents the asynchronous operation. + [Fact] + public async Task AuditEvents_pull_works_over_https_with_the_right_key() + { + using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust }; + var reply = await invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest { BatchSize = 1 }, CancellationToken.None); + Assert.False(reply.MoreAvailable); + } + + /// Verifies the site-calls pull succeeds over TLS with the right key. + /// A task that represents the asynchronous operation. + [Fact] + public async Task SiteCalls_pull_works_over_https_with_the_right_key() + { + using var invoker = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust }; + var reply = await invoker.InvokeAsync("site-a", _address, new PullSiteCallsRequest { BatchSize = 1 }, CancellationToken.None); + Assert.False(reply.MoreAvailable); + } + + /// Verifies the trace-events pull succeeds over TLS with the right key. + /// A task that represents the asynchronous operation. + [Fact] + public async Task TraceEvents_pull_works_over_https_with_the_right_key() + { + using var invoker = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust }; + var reply = await invoker.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None); + Assert.NotNull(reply); + } + + /// Verifies TLS does not replace the PSK: a wrong key is still PermissionDenied on all three pulls. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Wrong_key_is_still_rejected_over_https() + { + var wrong = new FixedPskProvider("some-other-sites-key"); + using var a = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust }; + using var s = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust }; + using var t = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust }; + + foreach (var call in new Func[] + { + () => a.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None), + () => s.InvokeAsync("site-a", _address, new PullSiteCallsRequest(), CancellationToken.None), + () => t.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None), + }) + { + var ex = await Assert.ThrowsAsync(call); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + } + + /// Verifies an untrusted server certificate fails the call before the PSK is sent. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Untrusted_server_certificate_fails_the_pull() + { + using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) + { + ConfigureHandler = h => h.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => false, + }; + var ex = await Assert.ThrowsAsync( + () => invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None)); + Assert.NotEqual(StatusCode.PermissionDenied, ex.StatusCode); + Assert.NotEqual(StatusCode.OK, ex.StatusCode); + } + + private sealed class FixedPskProvider(string key) : ISitePskProvider + { + /// + public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key); + + /// + public void Invalidate(string siteId) { } + } + + private sealed class StubSiteStreamService : SiteStreamService.SiteStreamServiceBase + { + /// + public override Task PullAuditEvents(PullAuditEventsRequest request, ServerCallContext context) => + Task.FromResult(new PullAuditEventsResponse()); + + /// + public override Task PullSiteCalls(PullSiteCallsRequest request, ServerCallContext context) => + Task.FromResult(new PullSiteCallsResponse()); + + /// + public override Task PullTraceEvents(PullTraceEventsRequest request, ServerCallContext context) => + Task.FromResult(new PullTraceEventsResponse()); + } +}