fix(grpc): control-plane PSK credentials work on https endpoints (ScadaBridge#64)
WithSiteCredentials always composed the per-site PSK over
ChannelCredentials.Insecure. Grpc.Net.Client refuses that on an https
address ("insecure channel credentials and can't use a HttpClient with a
'https' scheme"), so every https control-plane endpoint threw at channel
construction - in SitePairChannelProvider the throw was caught and the
site address logged as malformed, i.e. a TLS site was silently
undialable. The old comment claiming TLS needed no change here was wrong.
WithSiteCredentials now takes the endpoint: https composes over
ChannelCredentials.SecureSsl (PSK retained inside TLS, no insecure
opt-in); anything else, including callers that omit it, keeps the
previous composition unchanged. SitePairChannelProvider,
SiteStreamGrpcClient and CentralChannelProvider pass their endpoint. The
three AuditLog pull clients are deliberately left for after #42.
Tests: real Kestrel HTTP/2-over-TLS (ALPN) with the real
ControlPlaneAuthInterceptor - correct PSK accepted, wrong PSK still
PermissionDenied, untrusted server cert fails with an
AuthenticationException cause, the pre-fix composition throws on https,
http composition unchanged; plus each production builder accepts https
(mutation check: disabling the TLS branch turns those red).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -117,7 +117,7 @@ public sealed class CentralChannelProvider : IDisposable
|
|||||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||||
EnableMultipleHttp2Connections = true,
|
EnableMultipleHttp2Connections = true,
|
||||||
},
|
},
|
||||||
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteId);
|
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteId, endpoints[i]);
|
||||||
|
|
||||||
_channels[i] = GrpcChannel.ForAddress(endpoints[i], channelOptions);
|
_channels[i] = GrpcChannel.ForAddress(endpoints[i], channelOptions);
|
||||||
_clients[i] = new CentralControlService.CentralControlServiceClient(_channels[i]);
|
_clients[i] = new CentralControlService.CentralControlServiceClient(_channels[i]);
|
||||||
|
|||||||
@@ -65,8 +65,16 @@ public interface ISitePskProvider
|
|||||||
/// credentials to a plaintext channel by default, precisely because a bearer token on h2c is
|
/// credentials to a plaintext channel by default, precisely because a bearer token on h2c is
|
||||||
/// readable and replayable by anyone on the path. That is a real and accepted limitation here:
|
/// readable and replayable by anyone on the path. That is a real and accepted limitation here:
|
||||||
/// these listeners are h2c today and the boundary assumes a trusted network. The PSK raises the
|
/// these listeners are h2c today and the boundary assumes a trusted network. The PSK raises the
|
||||||
/// bar from "anyone who can reach the port" to "anyone who can read the traffic"; TLS on these
|
/// bar from "anyone who can reach the port" to "anyone who can read the traffic".
|
||||||
/// listeners is the follow-on hardening and requires no change to this code.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>TLS (ScadaBridge#64).</b> An earlier version of this comment claimed TLS would need no change
|
||||||
|
/// here. It did: Grpc.Net.Client refuses an <c>https://</c> address on a channel whose credentials
|
||||||
|
/// are composed over <see cref="ChannelCredentials.Insecure"/> ("insecure channel credentials and
|
||||||
|
/// can't use a HttpClient with a 'https' scheme"), so every https endpoint threw at channel
|
||||||
|
/// construction. <see cref="WithSiteCredentials"/> now takes the endpoint and composes over
|
||||||
|
/// <see cref="ChannelCredentials.SecureSsl"/> for <c>https</c> — the PSK still rides every call,
|
||||||
|
/// now inside TLS — and keeps the insecure composition, byte for byte, for <c>http</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class ControlPlaneCredentials
|
public static class ControlPlaneCredentials
|
||||||
@@ -119,9 +127,15 @@ public static class ControlPlaneCredentials
|
|||||||
/// <param name="options">Channel options being built.</param>
|
/// <param name="options">Channel options being built.</param>
|
||||||
/// <param name="provider">Key provider, or null to leave the channel unauthenticated.</param>
|
/// <param name="provider">Key provider, or null to leave the channel unauthenticated.</param>
|
||||||
/// <param name="siteId">The site this channel talks to.</param>
|
/// <param name="siteId">The site this channel talks to.</param>
|
||||||
|
/// <param name="endpoint">
|
||||||
|
/// The address the channel will dial. An <c>https</c> address composes the call credentials over
|
||||||
|
/// TLS channel credentials (ScadaBridge#64); anything else — including null, for callers that
|
||||||
|
/// predate it — keeps the h2c composition. Must be the same address passed to
|
||||||
|
/// <c>GrpcChannel.ForAddress</c>, or an https channel throws at construction.
|
||||||
|
/// </param>
|
||||||
/// <returns>The same options instance, for chaining.</returns>
|
/// <returns>The same options instance, for chaining.</returns>
|
||||||
public static GrpcChannelOptions WithSiteCredentials(
|
public static GrpcChannelOptions WithSiteCredentials(
|
||||||
this GrpcChannelOptions options, ISitePskProvider? provider, string? siteId)
|
this GrpcChannelOptions options, ISitePskProvider? provider, string? siteId, string? endpoint = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
@@ -130,9 +144,20 @@ public static class ControlPlaneCredentials
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
options.Credentials = ChannelCredentials.Create(
|
if (IsTlsEndpoint(endpoint))
|
||||||
ChannelCredentials.Insecure, ForSite(provider, siteId));
|
{
|
||||||
options.UnsafeUseInsecureChannelCallCredentials = true;
|
// TLS: the server certificate is validated by the HTTP handler (OS trust store by
|
||||||
|
// default, or the handler's own SslOptions), and the call credentials need no opt-in.
|
||||||
|
options.Credentials = ChannelCredentials.Create(
|
||||||
|
ChannelCredentials.SecureSsl, ForSite(provider, siteId));
|
||||||
|
options.UnsafeUseInsecureChannelCallCredentials = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
options.Credentials = ChannelCredentials.Create(
|
||||||
|
ChannelCredentials.Insecure, ForSite(provider, siteId));
|
||||||
|
options.UnsafeUseInsecureChannelCallCredentials = true;
|
||||||
|
}
|
||||||
if (options.HttpHandler is not PskRejectionRefreshHandler)
|
if (options.HttpHandler is not PskRejectionRefreshHandler)
|
||||||
{
|
{
|
||||||
// Null means "let GrpcChannel build its default", which is this same handler shape.
|
// Null means "let GrpcChannel build its default", which is this same handler shape.
|
||||||
@@ -144,6 +169,13 @@ public static class ControlPlaneCredentials
|
|||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="endpoint"/> is an absolute <c>https</c> URI.</summary>
|
||||||
|
/// <param name="endpoint">The channel address, or null.</param>
|
||||||
|
/// <returns>Whether the channel will run over TLS.</returns>
|
||||||
|
internal static bool IsTlsEndpoint(string? endpoint) =>
|
||||||
|
Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)
|
||||||
|
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -584,7 +584,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
|
||||||
EnableMultipleHttp2Connections = true
|
EnableMultipleHttp2Connections = true
|
||||||
}
|
}
|
||||||
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId);
|
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId, endpoint);
|
||||||
|
|
||||||
return GrpcChannel.ForAddress(endpoint, channelOptions);
|
return GrpcChannel.ForAddress(endpoint, channelOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
|||||||
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
|
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
|
||||||
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
|
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
|
||||||
}
|
}
|
||||||
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteIdentifier));
|
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteIdentifier, endpoint));
|
||||||
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
|
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every production builder of a PSK-credentialed control-plane channel must accept an
|
||||||
|
/// <c>https://</c> endpoint (ScadaBridge#64). Before the endpoint was threaded into
|
||||||
|
/// <see cref="ControlPlaneCredentials.WithSiteCredentials"/>, each composed its credentials over
|
||||||
|
/// insecure channel credentials and Grpc.Net.Client threw at construction for any https address —
|
||||||
|
/// in <see cref="SitePairChannelProvider"/> that throw was caught and the site address logged as
|
||||||
|
/// malformed, so a TLS site was silently undialable.
|
||||||
|
/// </summary>
|
||||||
|
public class HttpsEndpointChannelTests
|
||||||
|
{
|
||||||
|
private const string Site = "site-a";
|
||||||
|
|
||||||
|
/// <summary>Verifies central's per-site pair provider builds a channel for an https site address.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task SitePairChannelProvider_builds_an_https_site_channel()
|
||||||
|
{
|
||||||
|
using var provider = new SitePairChannelProvider(
|
||||||
|
new FixedKeyProvider(),
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<SitePairChannelProvider>.Instance,
|
||||||
|
handlerFactory: null,
|
||||||
|
reachabilityProbe: (_, _) => Task.FromResult(false));
|
||||||
|
|
||||||
|
provider.UpdateSite(Site, "https://site-a-node-a:8083", nodeBEndpoint: null);
|
||||||
|
|
||||||
|
var target = await provider.ExecuteAsync(
|
||||||
|
Site, (channel, _) => Task.FromResult(channel.Target), CancellationToken.None);
|
||||||
|
Assert.Equal("site-a-node-a:8083", target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a site's central channel provider builds https channels for both centrals.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void CentralChannelProvider_builds_https_central_channels()
|
||||||
|
{
|
||||||
|
using var provider = new CentralChannelProvider(
|
||||||
|
new[] { "https://central-a:8083", "https://central-b:8083" },
|
||||||
|
new FixedKeyProvider(),
|
||||||
|
Site,
|
||||||
|
new CommunicationOptions(),
|
||||||
|
NullLogger.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the site stream client builds an https channel with credentials.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SiteStreamGrpcClient_builds_an_https_channel()
|
||||||
|
{
|
||||||
|
using var client = new SiteStreamGrpcClient(
|
||||||
|
"https://site-a-node-a:8083",
|
||||||
|
NullLogger<SiteStreamGrpcClient>.Instance,
|
||||||
|
new CommunicationOptions(),
|
||||||
|
new FixedKeyProvider(),
|
||||||
|
Site);
|
||||||
|
|
||||||
|
Assert.Equal("https://site-a-node-a:8083", client.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the scheme check behind the composition choice.</summary>
|
||||||
|
/// <param name="endpoint">The endpoint.</param>
|
||||||
|
/// <param name="expected">Whether it is TLS.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://host:8083", true)]
|
||||||
|
[InlineData("HTTPS://host:8083", true)]
|
||||||
|
[InlineData("http://host:8083", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData("not a url", false)]
|
||||||
|
public void IsTlsEndpoint_matches_only_absolute_https(string? endpoint, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ControlPlaneCredentials.IsTlsEndpoint(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedKeyProvider : ISitePskProvider
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new("k");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Invalidate(string siteId) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
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.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The control-plane PSK over a real TLS listener (ScadaBridge#64): a Kestrel HTTP/2 endpoint with
|
||||||
|
/// a certificate (h2 negotiated by ALPN), the real <see cref="ControlPlaneAuthInterceptor"/>, and a
|
||||||
|
/// client channel built by <see cref="ControlPlaneCredentials.WithSiteCredentials"/> exactly as the
|
||||||
|
/// production channel builders do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Microsoft.AspNetCore.TestHost.TestServer"/> (what <c>ControlPlaneAuthEndToEndTests</c>
|
||||||
|
/// uses) has no TLS, so this binds real Kestrel on a loopback ephemeral port. The client trusts the
|
||||||
|
/// generated certificate by pinning its thumbprint in the handler's validation callback — the test
|
||||||
|
/// stand-in for "the node trusts the issuing CA"; production uses the OS trust store.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The first test is the reason the endpoint argument exists: before it, every channel composed its
|
||||||
|
/// PSK over insecure channel credentials, and Grpc.Net.Client refuses that on an https address.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ControlPlaneTlsEndToEndTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string SiteKey = "the-site-a-preshared-key";
|
||||||
|
|
||||||
|
private X509Certificate2 _certificate = null!;
|
||||||
|
private IHost _host = null!;
|
||||||
|
private string _httpsAddress = 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());
|
||||||
|
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||||
|
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, critical: false)); // serverAuth
|
||||||
|
using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
|
||||||
|
// Round-trip through PFX so the private key is usable by SslStream on every platform.
|
||||||
|
_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 =>
|
||||||
|
{
|
||||||
|
// By TYPE, interceptor not in DI — exactly as Program.cs registers it.
|
||||||
|
services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>());
|
||||||
|
services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey }));
|
||||||
|
})
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseEndpoints(e => e.MapGrpcService<EchoSiteStreamService>());
|
||||||
|
}))
|
||||||
|
.StartAsync();
|
||||||
|
|
||||||
|
var bound = _host.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||||
|
_httpsAddress = $"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 SocketsHttpHandler PinnedHandler() => new()
|
||||||
|
{
|
||||||
|
EnableMultipleHttp2Connections = true,
|
||||||
|
SslOptions =
|
||||||
|
{
|
||||||
|
RemoteCertificateValidationCallback = (_, cert, _, _) =>
|
||||||
|
cert is not null && string.Equals(
|
||||||
|
cert.GetCertHashString(), _certificate.Thumbprint, StringComparison.OrdinalIgnoreCase),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private GrpcChannel Channel(string key, string? endpointForCredentials) =>
|
||||||
|
GrpcChannel.ForAddress(_httpsAddress, new GrpcChannelOptions { HttpHandler = PinnedHandler() }
|
||||||
|
.WithMessageLimits()
|
||||||
|
.WithSiteCredentials(new FixedPskProvider(key), "site-a", endpointForCredentials));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the pre-#64 composition (no endpoint → insecure credentials) cannot even build an
|
||||||
|
/// https channel. This is the defect the endpoint argument fixes; if Grpc.Net.Client ever stops
|
||||||
|
/// refusing it, this test says so.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Https_channel_with_insecure_composition_throws_at_construction()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => Channel(SiteKey, endpointForCredentials: null));
|
||||||
|
|
||||||
|
Assert.Contains("insecure channel credentials", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a correctly keyed call succeeds over TLS and carries the site header.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Correct_key_is_accepted_over_tls()
|
||||||
|
{
|
||||||
|
using var channel = Channel(SiteKey, _httpsAddress);
|
||||||
|
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||||
|
|
||||||
|
var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 });
|
||||||
|
|
||||||
|
Assert.False(reply.MoreAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies TLS does not replace the PSK: a wrong key over a perfectly good TLS channel is still
|
||||||
|
/// refused by the interceptor.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_key_is_still_rejected_over_tls()
|
||||||
|
{
|
||||||
|
using var channel = Channel("some-other-sites-key", _httpsAddress);
|
||||||
|
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the client validates the server certificate: a handler that does not trust it fails
|
||||||
|
/// the call rather than sending the PSK to an unverified peer.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Untrusted_server_certificate_fails_the_call()
|
||||||
|
{
|
||||||
|
var distrusting = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => false },
|
||||||
|
};
|
||||||
|
using var channel = GrpcChannel.ForAddress(_httpsAddress, new GrpcChannelOptions { HttpHandler = distrusting }
|
||||||
|
.WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", _httpsAddress));
|
||||||
|
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||||
|
|
||||||
|
// Grpc.Net.Client surfaces a failed handshake as a transport error; what matters is that the
|
||||||
|
// cause is the certificate check, i.e. the call never reached the server.
|
||||||
|
Assert.NotEqual(StatusCode.OK, ex.StatusCode);
|
||||||
|
Assert.Contains(Causes(ex.Status.DebugException), e => e is System.Security.Authentication.AuthenticationException);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Exception> Causes(Exception? ex)
|
||||||
|
{
|
||||||
|
for (; ex is not null; ex = ex.InnerException)
|
||||||
|
{
|
||||||
|
yield return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the h2c composition is unchanged for an http endpoint: insecure channel credentials
|
||||||
|
/// with the explicit opt-in, as before #64.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Http_endpoint_keeps_the_h2c_composition()
|
||||||
|
{
|
||||||
|
var options = new GrpcChannelOptions()
|
||||||
|
.WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", "http://site-a:8083");
|
||||||
|
|
||||||
|
Assert.True(options.UnsafeUseInsecureChannelCallCredentials);
|
||||||
|
// Building the channel on the http address must still succeed.
|
||||||
|
using var channel = GrpcChannel.ForAddress("http://site-a:8083", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the https endpoint drops the insecure opt-in.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Https_endpoint_drops_the_insecure_opt_in()
|
||||||
|
{
|
||||||
|
var options = new GrpcChannelOptions()
|
||||||
|
.WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", "https://site-a:8083");
|
||||||
|
|
||||||
|
Assert.False(options.UnsafeUseInsecureChannelCallCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedPskProvider(string key) : ISitePskProvider
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Invalidate(string siteId) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stub service: the interceptor is under test, not the service.</summary>
|
||||||
|
private sealed class EchoSiteStreamService : SiteStreamService.SiteStreamServiceBase
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override Task<PullAuditEventsResponse> PullAuditEvents(
|
||||||
|
PullAuditEventsRequest request, ServerCallContext context) =>
|
||||||
|
Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user