fix(auditlog): pull clients attach the site PSK inside TLS on https endpoints (ScadaBridge#108)
GrpcPullAuditEventsInvoker, GrpcPullSiteCallsInvoker and
GrpcPullTraceEventsInvoker called WithSiteCredentials without the endpoint,
so they composed the PSK over insecure channel credentials and threw at
channel construction for any https site address. They now pass the endpoint
(same change as 9138f3ed): https composes over SecureSsl, http is unchanged.
An internal ConfigureHandler seam lets tests trust a generated certificate;
production leaves it null.
Tests: real Kestrel HTTP/2-over-TLS with the real ControlPlaneAuthInterceptor
for all three pulls (correct PSK accepted, wrong PSK PermissionDenied,
untrusted certificate fails). Reverting the fix turns all five red.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The three AuditLog pull invokers over a real HTTPS gRPC listener (ScadaBridge#108): real Kestrel
|
||||
/// HTTP/2 over TLS (ALPN), the real <see cref="ControlPlaneAuthInterceptor"/>, and the production
|
||||
/// invokers. Same shape as <see cref="ControlPlaneTlsEndToEndTests"/> (ScadaBridge#64). Before the
|
||||
/// invokers passed their endpoint to <c>WithSiteCredentials</c> they composed the PSK over insecure
|
||||
/// credentials and threw at channel construction for any https site address.
|
||||
/// </summary>
|
||||
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!;
|
||||
|
||||
/// <summary>Generates a localhost certificate and starts a TLS gRPC host with the real interceptor.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
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<ControlPlaneAuthInterceptor>());
|
||||
services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey }));
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(e => e.MapGrpcService<StubSiteStreamService>());
|
||||
}))
|
||||
.StartAsync();
|
||||
|
||||
var bound = _host.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
_address = $"https://localhost:{new Uri(bound).Port}";
|
||||
}
|
||||
|
||||
/// <summary>Stops the host and releases the certificate.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
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);
|
||||
|
||||
/// <summary>Verifies the audit-events pull succeeds over TLS with the right key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the site-calls pull succeeds over TLS with the right key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the trace-events pull succeeds over TLS with the right key.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies TLS does not replace the PSK: a wrong key is still PermissionDenied on all three pulls.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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<Task>[]
|
||||
{
|
||||
() => 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<RpcException>(call);
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies an untrusted server certificate fails the call before the PSK is sent.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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<RpcException>(
|
||||
() => 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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(string siteId) { }
|
||||
}
|
||||
|
||||
private sealed class StubSiteStreamService : SiteStreamService.SiteStreamServiceBase
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<PullAuditEventsResponse> PullAuditEvents(PullAuditEventsRequest request, ServerCallContext context) =>
|
||||
Task.FromResult(new PullAuditEventsResponse());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<PullSiteCallsResponse> PullSiteCalls(PullSiteCallsRequest request, ServerCallContext context) =>
|
||||
Task.FromResult(new PullSiteCallsResponse());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<PullTraceEventsResponse> PullTraceEvents(PullTraceEventsRequest request, ServerCallContext context) =>
|
||||
Task.FromResult(new PullTraceEventsResponse());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user