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()); } }