From 6bfa4fe8842910858fa5156587b6eef4ba27383d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 06:54:23 -0400 Subject: [PATCH 01/27] docs: design for gateway TLS auto-cert and lenient client trust --- .../2026-06-01-gateway-cert-autogen-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/plans/2026-06-01-gateway-cert-autogen-design.md diff --git a/docs/plans/2026-06-01-gateway-cert-autogen-design.md b/docs/plans/2026-06-01-gateway-cert-autogen-design.md new file mode 100644 index 0000000..e0dabc7 --- /dev/null +++ b/docs/plans/2026-06-01-gateway-cert-autogen-design.md @@ -0,0 +1,150 @@ +# Gateway TLS Auto-Certificate and Lenient Client Trust — Design + +Date: 2026-06-01 +Status: Approved (brainstorming), pending implementation plan + +## Problem + +The gateway can serve gRPC and the dashboard over TLS, but only if an operator +supplies a certificate via the Kestrel `https://` endpoint config. With no cert, +an `https` endpoint fails at startup with Kestrel's opaque "No server certificate +was specified" error. Both current deployments therefore run plaintext (`h2c`), +exposing the API key and request payloads on the wire. + +`mxaccessgw` is an internal tool. The goal is for TLS to "just work" with zero PKI +management: the gateway fabricates its own long-lived certificate when an HTTPS +endpoint is configured without one, and clients accept whatever certificate is +presented unless an operator explicitly opts into pinning. + +## Decisions + +1. **Gateway = fill-missing-cert-only.** No new "enable TLS" switch. TLS is still + driven by configuring a Kestrel `https://` endpoint. New behavior: when an + HTTPS endpoint has no `Certificate` section, the gateway generates/loads a + persisted self-signed cert instead of failing. Plaintext-only hosts are + untouched — no certificate or key material is ever written for them. +2. **Persist & reuse.** The self-signed cert is saved as a PFX under + `C:\ProgramData\MxGateway\certs`, reused across restarts, regenerated only if + missing, expired, or unreadable. Stable thumbprint; survives restarts; any + CA-pinning client keeps working. +3. **Clients = lenient TLS, plaintext default.** When a client connects over TLS + without a pinned CA, it skips verification (accepts any cert). Pinning a CA file + restores full verification. The per-client connection default (mostly + plaintext/`http`) does not change — TLS is still opt-in via the endpoint scheme. + +**Scope boundary:** the gateway↔worker named-pipe IPC is unchanged (local, +OS-secured by the pipe ACL). This work touches only the public gRPC/dashboard +transport and the five language clients. + +## Gateway component + +New type `SelfSignedCertificateProvider` in +`src/ZB.MOM.WW.MxGateway.Server/Security/Tls/`. + +1. **Detect need.** Inspect `Kestrel:Endpoints:*` configuration at startup. If any + endpoint has an `https://` URL and no `Certificate` subsection, a default cert + is needed. If none do, the provider is a no-op (no file written). +2. **Load-or-create.** Look for the persisted PFX. If present, valid, and + unexpired, load it. Otherwise generate and persist. +3. **Generate.** `CertificateRequest` with **ECDSA P-256**, `notBefore = now - 1 + day` (clock-skew slack), `notAfter = now + ValidityYears`. SANs: `DNS=localhost`, + `DNS=`, `DNS=` when resolvable, plus + `IP=127.0.0.1` and `IP=::1`. Server-auth EKU. +4. **Persist securely.** Write the PFX with a random in-memory-only export password; + restrictive ACL (SYSTEM + Administrators + service account) on the `certs` + directory and file; atomic write (temp + rename). +5. **Wire into Kestrel.** In `GatewayApplication.CreateBuilder`, add + `builder.WebHost.ConfigureKestrel(o => o.ConfigureHttpsDefaults(h => + h.ServerCertificate = cert))`. `ConfigureHttpsDefaults` supplies the cert only + for HTTPS endpoints that did not specify their own, so an operator-configured + `Kestrel:Endpoints:*:Certificate` transparently overrides it. One hook covers + both the gRPC and dashboard ports. + +### New config block `MxGateway:Tls` + +All optional; the zero-config path needs none of them. + +| Option | Default | Purpose | +|---|---|---| +| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated cert lives | +| `Tls:ValidityYears` | `10` | Lifetime of the generated cert | +| `Tls:AdditionalDnsNames` | `[]` | Extra SANs (e.g. a load-balancer name) | +| `Tls:RegenerateIfExpired` | `true` | Auto-replace an expired persisted cert | + +Validated by `GatewayOptionsValidator`: path non-empty when TLS is active, +`ValidityYears` in 1–100. + +**Logging:** on generate/load, log thumbprint + SAN list + `notAfter` at +Information. Never log the PFX password or private key. + +## Client lenient-TLS behavior + +Uniform rule: **TLS on + no CA pinned ⇒ skip verification; CA pinned ⇒ full +verification.** No transport default changes. Each client also exposes an explicit +switch to force-disable leniency (strict-without-pinning) for the future. + +| Client | Mechanism | Effort | +|---|---|---| +| .NET | In `CreateHttpHandler`, when `UseTls` and `CaCertificatePath` empty, set `SslOptions.RemoteCertificateValidationCallback = (_,_,_,_) => true`. CA path keeps existing custom-root validation. | trivial | +| Go | In `buildCredentials`, when TLS and no `CACertFile`/`TLSConfig`, use `tls.Config{InsecureSkipVerify: true, ServerName: override}`. | trivial | +| Java | grpc-netty-shaded 1.76.0 ships `InsecureTrustManagerFactory`. When TLS and no CA, build `GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`. | easy | +| Python | grpc-python has no per-channel skip-verify. Fetch the server leaf cert at connect via `ssl.get_server_certificate((host, port))`, pass it as `root_certificates` to `ssl_channel_credentials`, plus `grpc.ssl_target_name_override`. Effectively trusts what is presented (TOFU). | moderate, special-cased | +| Rust | tonic 0.13.1 + rustls (`tls-ring`). Implement a custom `rustls::client::danger::ServerCertVerifier` that accepts everything, build a `rustls::ClientConfig` via `.dangerous().with_custom_certificate_verifier(...)`, feed it to the channel. May require a custom hyper-rustls connector if `ClientTlsConfig` will not take a raw rustls config. **Needs an API spike.** | highest | + +### Honesty caveats + +- **Python** is not literally "ignore the cert"; it pins whatever the server + presents on first contact via a separate unverified TLS probe. For a self-signed + internal cert this is the intended outcome. Documented as a difference. +- **Rust** leniency depends on the tonic 0.13 TLS surface. If a custom verifier is + disproportionately invasive, the fallback is to require a CA file for Rust TLS + (pin-only) and document Rust as the exception. + +## Error handling + +Gateway: +- Cert dir not writable / ACL fails ⇒ fail fast at startup with a diagnostic naming + the path and required permission. No silent in-memory fallback. +- Persisted PFX corrupt/unreadable ⇒ warn, regenerate, overwrite. +- Persisted cert expired ⇒ regenerate if `RegenerateIfExpired` (default), else fail + fast instructing the operator to delete it or enable regeneration. +- HTTPS endpoint configured but generation disabled / path empty ⇒ validator + rejects at startup rather than letting Kestrel throw its opaque error. + +Clients: surface unchanged. Skip-verify cannot itself raise. Python's pre-fetch +wraps connect failure into the existing connect-error type with the endpoint in the +message. Rust pin-only fallback surfaces the existing CA-file error. + +## Documentation (same commit as source, per CLAUDE.md) + +- `docs/GatewayConfiguration.md` — extend the TLS section: auto-generation, the + `MxGateway:Tls:*` block, persistence location/ACL, thumbprint logging, operator + override via `Kestrel:Endpoints:*:Certificate`. +- Each client README + `*ClientDesign.md` — "TLS is lenient by default; pin a CA to + verify," with Python TOFU and any Rust caveat noted. +- `docs/DesignDecisions.md` — record both posture choices and the why (internal + tool, no PKI) so they are not mistaken for an oversight. + +## Testing + +Gateway (`MxGateway.Tests`, no MXAccess): +- `SelfSignedCertificateProvider`: SANs, server-auth EKU, `notAfter ≈ now + + ValidityYears`, ECDSA P-256. +- Load-or-create: valid persisted PFX reused (same thumbprint); expired regenerates + when enabled; corrupt regenerates with a warning. +- Detection: HTTPS-without-cert engages; all-plaintext no-ops and writes no file; + endpoint with its own cert is not overridden. +- `GatewayOptionsValidator`: new `Tls:*` rules. +- Host integration: `Kestrel:Endpoints:Http:Url=https://127.0.0.1:0` builds and + binds (today it throws "no certificate specified"). + +Clients: each test project gets a lenient-TLS test against a throwaway self-signed +cert — connect with no CA succeeds; pinning a wrong CA fails (proves pinning still +verifies). Python exercises the pre-fetch path; mark opt-in if loopback timing is +flaky. Standard (non-live) tests; no MXAccess or external services. + +Cross-language: add a TLS variant note to `docs/CrossLanguageSmokeMatrix.md`; +running the matrix over TLS stays manual/opt-in, consistent with the existing gate. + +Per-component verification follows CLAUDE.md's source-update table (build + test +each touched component independently). From c4e7ddea707969b0d4f1769d4c6e9ba7f1b54322 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:01:58 -0400 Subject: [PATCH 02/27] docs: implementation plan for gateway TLS auto-cert and lenient client trust --- .../2026-06-01-gateway-cert-autogen-design.md | 16 +- ...-01-gateway-cert-autogen-implementation.md | 1202 +++++++++++++++++ ...-cert-autogen-implementation.md.tasks.json | 18 + 3 files changed, 1231 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-06-01-gateway-cert-autogen-implementation.md create mode 100644 docs/plans/2026-06-01-gateway-cert-autogen-implementation.md.tasks.json diff --git a/docs/plans/2026-06-01-gateway-cert-autogen-design.md b/docs/plans/2026-06-01-gateway-cert-autogen-design.md index e0dabc7..542b085 100644 --- a/docs/plans/2026-06-01-gateway-cert-autogen-design.md +++ b/docs/plans/2026-06-01-gateway-cert-autogen-design.md @@ -50,9 +50,12 @@ New type `SelfSignedCertificateProvider` in day` (clock-skew slack), `notAfter = now + ValidityYears`. SANs: `DNS=localhost`, `DNS=`, `DNS=` when resolvable, plus `IP=127.0.0.1` and `IP=::1`. Server-auth EKU. -4. **Persist securely.** Write the PFX with a random in-memory-only export password; - restrictive ACL (SYSTEM + Administrators + service account) on the `certs` - directory and file; atomic write (temp + rename). +4. **Persist securely.** Write the PFX with an **empty** export password (a random + in-memory password cannot be reused across restarts, which the persist-and-reuse + decision requires); protect the private key with a restrictive ACL (SYSTEM + + Administrators + service account) on the `certs` directory and file on Windows, + and `0600` on non-Windows; atomic write (temp + rename). After generating, the + cert is reloaded from the persisted PFX so Kestrel always serves the on-disk key. 5. **Wire into Kestrel.** In `GatewayApplication.CreateBuilder`, add `builder.WebHost.ConfigureKestrel(o => o.ConfigureHttpsDefaults(h => h.ServerCertificate = cert))`. `ConfigureHttpsDefaults` supplies the cert only @@ -71,8 +74,11 @@ All optional; the zero-config path needs none of them. | `Tls:AdditionalDnsNames` | `[]` | Extra SANs (e.g. a load-balancer name) | | `Tls:RegenerateIfExpired` | `true` | Auto-replace an expired persisted cert | -Validated by `GatewayOptionsValidator`: path non-empty when TLS is active, -`ValidityYears` in 1–100. +Validated by `GatewayOptionsValidator`: `ValidityYears` in 1–100, +`SelfSignedCertPath` is a valid path shape when non-blank, and +`AdditionalDnsNames` entries are non-blank. (The "https endpoint exists but cert +path is blank" fail-fast lives in the bootstrap/provider, not the validator, +because the validator only sees the `MxGateway` section, not `Kestrel:Endpoints`.) **Logging:** on generate/load, log thumbprint + SAN list + `notAfter` at Information. Never log the PFX password or private key. diff --git a/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md b/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md new file mode 100644 index 0000000..1b998c1 --- /dev/null +++ b/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md @@ -0,0 +1,1202 @@ +# Gateway TLS Auto-Certificate + Lenient Client Trust — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Make the gateway auto-generate and persist a long-lived self-signed certificate for any HTTPS Kestrel endpoint that has no certificate configured, and make all five language clients accept any server certificate by default when using TLS without a pinned CA. + +**Architecture:** Gateway gains a `SelfSignedCertificateProvider` + `KestrelTlsInspector` under `Security/Tls/`, wired in `GatewayApplication.CreateBuilder` via `ConfigureKestrel(o => o.ConfigureHttpsDefaults(...))` so it only fills a missing default cert. A new optional `MxGateway:Tls` config block tunes path/validity. Each client gets a `RequireCertificateValidation` opt-out flag; lenient-by-default skip-verify is added at the existing TLS-construction site. Plaintext deployments are untouched. + +**Tech Stack:** .NET 10 (ASP.NET Core Kestrel, `System.Security.Cryptography.X509Certificates`), Go (`crypto/tls`, grpc-go), Java (grpc-netty-shaded 1.76, Netty `InsecureTrustManagerFactory`), Python (`grpc.aio`, `ssl`), Rust (tonic 0.13.1 + rustls `tls-ring`). + +**Branch:** `feat/tls-cert-autogen` + +--- + +## Environment notes (read first) + +- **Where to build/test the gateway:** the gateway server is `net10.0`. If the local macOS .NET 10 SDK can build `src/ZB.MOM.WW.MxGateway.Server` + `src/ZB.MOM.WW.MxGateway.Tests`, run there. Otherwise build/test on the Windows dev host (`ssh windev` / `dohertj2@10.100.0.48`, passwordless) per the project deploy memory. The cert provider's X509 generation/load tests are cross-platform; the **ACL-hardening assertions are Windows-only** and must be guarded with `OperatingSystem.IsWindows()` (skip the ACL assertion off-Windows). +- **Clients** build/test on macOS directly: `dotnet`, `go`, `gradle`, `pytest`, `cargo`. Toolchain paths are in `docs/ToolchainLinks.md`. +- **Style:** `docs/style-guides/CSharpStyleGuide.md` — file-scoped namespaces, `sealed`, `Async` suffix, nullable enabled, `TreatWarningsAsErrors=true` (a new analyzer warning breaks the build). Platform-specific code needs `[SupportedOSPlatform("windows")]` on the helper + an `OperatingSystem.IsWindows()` guard at the call site, or CA1416 fails the build. +- **Never log secrets:** thumbprint/SAN/notAfter are fine to log; PFX bytes, private keys, and any client value/password are not. + +--- + +## Task 1: Add `TlsOptions` config class + bind into `GatewayOptions` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 11 (client tasks) + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs` +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs` + +**Step 1: Write the failing test** (`TlsOptionsBindingTests.cs`) + +```csharp +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +public sealed class TlsOptionsBindingTests +{ + [Fact] + public void Defaults_AreApplied_WhenSectionAbsent() + { + TlsOptions options = new(); + Assert.Equal(10, options.ValidityYears); + Assert.True(options.RegenerateIfExpired); + Assert.Empty(options.AdditionalDnsNames); + Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath)); + } + + [Fact] + public void Binds_FromMxGatewayTlsSection() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["MxGateway:Tls:ValidityYears"] = "5", + ["MxGateway:Tls:SelfSignedCertPath"] = @"C:\tmp\gw.pfx", + ["MxGateway:Tls:RegenerateIfExpired"] = "false", + ["MxGateway:Tls:AdditionalDnsNames:0"] = "gw.internal", + }) + .Build(); + + GatewayOptions options = config.GetSection(GatewayOptions.SectionName).Get()!; + + Assert.Equal(5, options.Tls.ValidityYears); + Assert.Equal(@"C:\tmp\gw.pfx", options.Tls.SelfSignedCertPath); + Assert.False(options.Tls.RegenerateIfExpired); + Assert.Equal("gw.internal", Assert.Single(options.Tls.AdditionalDnsNames)); + } +} +``` + +**Step 2: Run, expect FAIL** (no `TlsOptions` / no `GatewayOptions.Tls`) + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~TlsOptionsBindingTests` +Expected: compile error / FAIL. + +**Step 3: Create `TlsOptions.cs`** + +```csharp +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Options controlling the gateway's self-signed certificate auto-generation. +/// Only consulted when a Kestrel HTTPS endpoint is configured without its own +/// certificate; plaintext deployments never trigger generation. +/// +public sealed class TlsOptions +{ + /// Path to the persisted self-signed PFX. Reused across restarts. + public string SelfSignedCertPath { get; init; } = + @"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx"; + + /// Lifetime in years of a freshly generated certificate. + public int ValidityYears { get; init; } = 10; + + /// Extra DNS SANs to embed (e.g. a load-balancer name). + public IReadOnlyList AdditionalDnsNames { get; init; } = []; + + /// Regenerate the persisted certificate when it has expired. + public bool RegenerateIfExpired { get; init; } = true; +} +``` + +Add to `GatewayOptions.cs` (after the `Alarms` property): + +```csharp + /// Gets self-signed TLS certificate auto-generation options. + public TlsOptions Tls { get; init; } = new(); +``` + +**Step 4: Run, expect PASS** (same command as Step 2). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs \ + src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs +git commit -m "feat(gateway): add MxGateway:Tls options block" +``` + +--- + +## Task 2: Validate `MxGateway:Tls` in `GatewayOptionsValidator` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 7–11 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs` (add cases; create file if absent — check first) + +**Step 1: Write failing tests** (add to the validator test class; mirror existing style) + +```csharp +[Fact] +public void Validate_Fails_WhenTlsValidityYearsOutOfRange() +{ + GatewayOptions options = ValidOptions() with { }; // use existing helper; set Tls below + GatewayOptions withBadTls = CloneWithTls(options, new TlsOptions { ValidityYears = 0 }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); +} + +[Fact] +public void Validate_Fails_WhenAdditionalDnsNameBlank() +{ + GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); +} +``` + +> If the test class has no `ValidOptions()`/`CloneWithTls` helper, construct a minimal valid `GatewayOptions` inline (copy the pattern already used by neighbouring validator tests). The assertions on failure messages are what matter. + +**Step 2: Run, expect FAIL** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayOptionsValidatorTests` + +**Step 3: Implement** — in `GatewayOptionsValidator.Validate`, add `ValidateTls(options.Tls, failures);` after `ValidateAlarms(...)`, then add: + +```csharp + private const int MinimumCertValidityYears = 1; + private const int MaximumCertValidityYears = 100; + + private static void ValidateTls(TlsOptions options, List failures) + { + if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears) + { + failures.Add( + $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); + } + + AddIfInvalidPath( + options.SelfSignedCertPath, + "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", + failures); + + foreach (string dns in options.AdditionalDnsNames) + { + if (string.IsNullOrWhiteSpace(dns)) + { + failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); + } + } + } +``` + +**Step 4: Run, expect PASS.** + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +git commit -m "feat(gateway): validate MxGateway:Tls options" +``` + +--- + +## Task 3: `SelfSignedCertificateProvider.GenerateCertificate` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7–11 (NOT with Task 4/6 — same file) + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs` + +**Step 1: Write failing tests** + +```csharp +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Tls; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls; + +public sealed class SelfSignedCertificateProviderTests +{ + private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time) + => new(options, NullLogger.Instance, time); + + [Fact] + public void GenerateCertificate_HasExpectedSansEkuAndValidity() + { + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] }; + + using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate(); + + Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date); + Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime); + Assert.True(cert.HasPrivateKey); + + string sans = ReadSubjectAltNames(cert); + Assert.Contains("localhost", sans); + Assert.Contains("gw.internal", sans); + + X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType().Single(); + Assert.Contains(eku.EnhancedKeyUsages.Cast(), + o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth + } + + private static string ReadSubjectAltNames(X509Certificate2 cert) + => cert.Extensions + .First(e => e.Oid?.Value == "2.5.29.17") + .Format(false); +} +``` + +> Uses `Microsoft.Extensions.TimeProvider.Testing` (`FakeTimeProvider`). If the test project lacks the package, add `` (version via the repo's central package management if present — check `Directory.Packages.props`). + +**Step 2: Run, expect FAIL.** + +Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~SelfSignedCertificateProviderTests` + +**Step 3: Implement** (`SelfSignedCertificateProvider.cs`) + +```csharp +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; + +/// +/// Generates and persists a long-lived self-signed certificate used as the +/// Kestrel HTTPS default when no operator certificate is configured. +/// +public sealed class SelfSignedCertificateProvider +{ + private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + + private readonly TlsOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public SelfSignedCertificateProvider( + TlsOptions options, + ILogger logger, + TimeProvider timeProvider) + { + _options = options; + _logger = logger; + _timeProvider = timeProvider; + } + + /// Creates a fresh in-memory ECDSA P-256 self-signed certificate. + public X509Certificate2 GenerateCertificate() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest request = new( + new X500DistinguishedName("CN=MxAccessGateway Self-Signed"), + key, + HashAlgorithmName.SHA256); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid(ServerAuthOid, "Server Authentication")], + critical: false)); + + SubjectAlternativeNameBuilder san = new(); + san.AddDnsName("localhost"); + string machine = Environment.MachineName; + if (!string.IsNullOrWhiteSpace(machine)) + { + san.AddDnsName(machine); + } + + foreach (string extra in _options.AdditionalDnsNames) + { + if (!string.IsNullOrWhiteSpace(extra)) + { + san.AddDnsName(extra); + } + } + + san.AddIpAddress(IPAddress.Loopback); + san.AddIpAddress(IPAddress.IPv6Loopback); + request.CertificateExtensions.Add(san.Build()); + + DateTimeOffset now = _timeProvider.GetUtcNow(); + return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears)); + } +} +``` + +**Step 4: Run, expect PASS.** + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +git commit -m "feat(gateway): generate self-signed ECDSA cert with SANs" +``` + +--- + +## Task 4: `SelfSignedCertificateProvider.LoadOrCreate` (persist, reuse, regenerate, ACL) + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7–11 (NOT Task 3/6 — same file) +**Depends on:** Task 3 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs` + +**Step 1: Write failing tests** (add to existing class; write under a temp dir) + +```csharp +[Fact] +public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint() +{ + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path }; + + using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate(); + Assert.True(File.Exists(path)); + using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate(); + + Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated + } + finally { Directory.Delete(dir, recursive: true); } +} + +[Fact] +public void LoadOrCreate_Regenerates_WhenPersistedCertExpired() +{ + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 }; + + using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate(); + time.Advance(TimeSpan.FromDays(800)); // past 1-year validity + using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate(); + + Assert.NotEqual(first.Thumbprint, second.Thumbprint); + } + finally { Directory.Delete(dir, recursive: true); } +} + +[Fact] +public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt() +{ + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + File.WriteAllText(path, "not a pfx"); + TlsOptions options = new() { SelfSignedCertPath = path }; + using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate(); + Assert.True(cert.HasPrivateKey); + } + finally { Directory.Delete(dir, recursive: true); } +} + +[Fact] +public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled() +{ + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false }; + using (CreateProvider(options, time).LoadOrCreate()) { } + time.Advance(TimeSpan.FromDays(800)); + Assert.Throws(() => CreateProvider(options, time).LoadOrCreate()); + } + finally { Directory.Delete(dir, recursive: true); } +} +``` + +**Step 2: Run, expect FAIL.** + +**Step 3: Implement** — add to `SelfSignedCertificateProvider`: + +```csharp + /// Loads the persisted certificate, regenerating when missing, + /// expired (and allowed), or unreadable. + public X509Certificate2 LoadOrCreate() + { + string path = _options.SelfSignedCertPath; + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException( + "MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate."); + } + + if (File.Exists(path)) + { + try + { + X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime) + { + Log("Loaded", existing); + return existing; + } + + if (!_options.RegenerateIfExpired) + { + string notAfter = existing.NotAfter.ToUniversalTime().ToString("u"); + existing.Dispose(); + throw new InvalidOperationException( + $"Persisted gateway certificate at '{path}' expired on {notAfter} " + + "and MxGateway:Tls:RegenerateIfExpired is false."); + } + + _logger.LogWarning( + "Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.", + path, existing.NotAfter.ToUniversalTime()); + existing.Dispose(); + } + catch (CryptographicException ex) + { + _logger.LogWarning(ex, + "Persisted gateway certificate at {Path} is unreadable; regenerating.", path); + } + } + + return GenerateAndPersist(path); + } + + private X509Certificate2 GenerateAndPersist(string path) + { + using X509Certificate2 generated = GenerateCertificate(); + byte[] pfx = generated.Export(X509ContentType.Pkcs12); + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string temp = path + ".tmp"; + File.WriteAllBytes(temp, pfx); + File.Move(temp, path, overwrite: true); + HardenPermissions(path); + + X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + Log("Generated", loaded); + return loaded; + } + + private static X509KeyStorageFlags KeyStorageFlags() + => OperatingSystem.IsWindows() + ? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable + : X509KeyStorageFlags.Exportable; + + private void HardenPermissions(string path) + { + if (OperatingSystem.IsWindows()) + { + HardenWindowsAcl(path); + } + else + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void HardenWindowsAcl(string path) + { + FileInfo file = new(path); + System.Security.AccessControl.FileSecurity security = new(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + foreach (System.Security.Principal.WellKnownSidType sid in new[] + { + System.Security.Principal.WellKnownSidType.LocalSystemSid, + System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid, + }) + { + System.Security.Principal.SecurityIdentifier identifier = new(sid, null); + security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + identifier, + System.Security.AccessControl.FileSystemRights.FullControl, + System.Security.AccessControl.AccessControlType.Allow)); + } + + file.SetAccessControl(security); + } + + private void Log(string action, X509Certificate2 cert) + { + string sans = cert.Extensions + .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")? + .Format(false) ?? "(none)"; + _logger.LogInformation( + "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", + action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans); + } +``` + +> Note: persisted PFX uses **no password** (reuse requires a reproducible secret; a random in-memory password could not be reloaded). The private key is protected by the file ACL (`0600` on non-Windows). `System.Security.AccessControl` is in the framework on `net10.0`; the `HardenWindowsAcl` method is platform-guarded so CA1416 passes. + +**Step 4: Run, expect PASS.** (On macOS the ACL branch is skipped; tests assert behavior, not ACL.) + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +git commit -m "feat(gateway): persist/reuse self-signed cert with hardened permissions" +``` + +--- + +## Task 5: `KestrelTlsInspector` — detect HTTPS-without-cert + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 7–11 + +**Files:** +- Create: `src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs` + +**Step 1: Write failing tests** + +```csharp +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Tls; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls; + +public sealed class KestrelTlsInspectorTests +{ + private static IConfiguration Config(params (string Key, string Value)[] entries) + => new ConfigurationBuilder() + .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) + .Build(); + + [Fact] + public void RequiresGeneratedCertificate_True_WhenHttpsEndpointHasNoCertificate() + => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( + Config(("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenAllEndpointsPlaintext() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config(("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5120")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasOwnCertificate() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"), + ("Kestrel:Endpoints:Http:Certificate:Path", @"C:\certs\real.pfx")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config())); +} +``` + +**Step 2: Run, expect FAIL.** + +**Step 3: Implement** + +```csharp +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; + +/// +/// Inspects the Kestrel configuration to decide whether the gateway must supply +/// a generated default certificate (an HTTPS endpoint exists with no certificate +/// of its own). +/// +public static class KestrelTlsInspector +{ + public static bool RequiresGeneratedCertificate(IConfiguration configuration) + { + IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints"); + foreach (IConfigurationSection endpoint in endpoints.GetChildren()) + { + string? url = endpoint["Url"]; + if (string.IsNullOrWhiteSpace(url) || + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + IConfigurationSection certificate = endpoint.GetSection("Certificate"); + bool hasOwnCertificate = + !string.IsNullOrWhiteSpace(certificate["Path"]) || + !string.IsNullOrWhiteSpace(certificate["Subject"]); + + if (!hasOwnCertificate) + { + return true; + } + } + + return false; + } +} +``` + +**Step 4: Run, expect PASS.** + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs +git commit -m "feat(gateway): detect HTTPS endpoints missing a certificate" +``` + +--- + +## Task 6: Wire auto-cert into `GatewayApplication.CreateBuilder` + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7–11 +**Depends on:** Task 1, Task 4, Task 5 + +**Files:** +- Modify: `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs:49-73` +- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs` + +**Step 1: Write failing test** — build the host with an in-memory HTTPS endpoint on an ephemeral port and assert it starts (today this throws "No server certificate was specified"). + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; + +public sealed class GatewayTlsBootstrapTests +{ + [Fact] + public async Task Host_StartsAndBinds_WhenHttpsEndpointHasNoCertificate() + { + string certDir = Directory.CreateTempSubdirectory().FullName; + try + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0"); + Environment.SetEnvironmentVariable( + "MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx")); + + WebApplication app = GatewayApplication.Build([]); + await app.StartAsync(); + await app.StopAsync(); + await app.DisposeAsync(); + } + finally + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null); + Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null); + Directory.Delete(certDir, recursive: true); + } + } +} +``` + +> If `GatewayApplication.Build` needs more env (auth pepper, worker path) to start, mirror whatever the existing `GatewayEndToEndFakeWorkerSmokeTests`/host tests set up. The assertion is simply that `StartAsync` does not throw. Keep this test in the same opt-in tier as other host-level tests if the suite gates them. + +**Step 2: Run, expect FAIL** (Kestrel throws on the cert-less HTTPS endpoint). + +**Step 3: Implement** — in `CreateBuilder`, after `StaticWebAssetsLoader.UseStaticWebAssets(...)` and before `builder.Services.AddGatewayConfiguration();`: + +```csharp + ConfigureSelfSignedTls(builder); +``` + +Add the private method to `GatewayApplication`: + +```csharp + private static void ConfigureSelfSignedTls(WebApplicationBuilder builder) + { + if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration)) + { + return; + } + + Configuration.TlsOptions tlsOptions = + builder.Configuration.GetSection("MxGateway:Tls").Get() + ?? new Configuration.TlsOptions(); + + using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole()); + Security.Tls.SelfSignedCertificateProvider provider = new( + tlsOptions, + loggerFactory.CreateLogger(), + TimeProvider.System); + + X509Certificate2 certificate = provider.LoadOrCreate(); + builder.WebHost.ConfigureKestrel(options => + options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate)); + } +``` + +Add `using System.Security.Cryptography.X509Certificates;` and `using Microsoft.Extensions.Logging;` to the file. + +**Step 4: Run, expect PASS.** + +**Step 5: Build the full server + run gateway tests** + +Run: +``` +dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj +``` +Expected: build clean (no CA1416 / nullable / warning-as-error breaks), tests PASS. + +**Step 6: Commit** + +```bash +git add src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs \ + src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs +git commit -m "feat(gateway): supply generated cert as Kestrel HTTPS default" +``` + +--- + +## Task 7: .NET client — lenient TLS by default + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1–6, 8, 9, 10, 11 + +**Files:** +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs` (add option + no new validation failure) +- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs:325-353` +- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/` (add a handler-construction or loopback-TLS test; follow existing test patterns) + +**Step 1: Write failing test** — assert that with `UseTls=true` and no `CaCertificatePath`, the built `SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback` is non-null (accept-all), and that setting `RequireCertificateValidation=true` leaves it null (OS default). If `CreateHttpHandler` is private, expose an `internal static` test seam (and `InternalsVisibleTo` the test project — check whether it already exists) or test via a loopback TLS server. + +```csharp +[Fact] +public void Handler_SkipsVerification_WhenTlsAndNoCaPinned() +{ + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + }; + using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options); + Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback); +} + +[Fact] +public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation() +{ + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + RequireCertificateValidation = true, + }; + using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options); + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); +} +``` + +**Step 2: Run, expect FAIL.** + +Run: `dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter FullyQualifiedName~Handler` + +**Step 3: Implement** + +In `MxGatewayClientOptions.cs`, add after `CaCertificatePath`: + +```csharp + /// + /// When true, TLS connections without a pinned + /// use the OS trust store. When false (default), the gateway certificate is + /// accepted without verification — appropriate for this internal tool's + /// auto-generated self-signed certificate. Pinning a CA always verifies. + /// + public bool RequireCertificateValidation { get; init; } +``` + +In `MxGatewayClient.cs`, change the `else` after the `CaCertificatePath` block (line ~352) so the no-CA path installs an accept-all callback unless strict: + +```csharp + if (!string.IsNullOrWhiteSpace(options.CaCertificatePath)) + { + // ... existing custom-root validation, unchanged ... + } + else if (!options.RequireCertificateValidation) + { + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } +``` + +Rename `CreateHttpHandler` to `internal static SocketsHttpHandler CreateHttpHandlerForTests` OR add an `internal` wrapper; ensure the client's `.csproj` has `InternalsVisibleTo` for the test assembly (add if missing). + +**Step 4: Run, expect PASS.** + +**Step 5: Build + test** + +Run: `dotnet build clients/dotnet/MxGateway.Client.sln && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj` + +**Step 6: Commit** + +```bash +git add clients/dotnet/ZB.MOM.WW.MxGateway.Client/ clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ +git commit -m "feat(client-dotnet): accept gateway cert by default over TLS" +``` + +--- + +## Task 8: Go client — lenient TLS by default + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1–7, 9, 10, 11 + +**Files:** +- Modify: `clients/go/mxgateway/options.go` (add `RequireCertificateValidation bool`) +- Modify: `clients/go/mxgateway/client.go:214-229` (`buildCredentials` final TLS branch) +- Test: `clients/go/mxgateway/` (add `client_tls_test.go`) + +**Step 1: Write failing test** — start an in-process TLS gRPC server with a self-signed cert, dial with default options (TLS, no CA), assert the RPC succeeds; then dial with `RequireCertificateValidation: true` and assert it fails with a cert error. (If a full server is heavy, at minimum unit-test `buildCredentials` returns credentials whose `tls.Config.InsecureSkipVerify` is true by default and false when `RequireCertificateValidation` — by exposing an internal helper that returns the `*tls.Config`.) + +**Step 2: Run, expect FAIL.** + +Run: `cd clients/go && go test ./... -run TLS` + +**Step 3: Implement** + +`options.go` — add field with doc: + +```go + // RequireCertificateValidation forces TLS certificate verification even when + // no CACertFile is pinned. Default false: the gateway's self-signed cert is + // accepted without verification (internal-tool posture). + RequireCertificateValidation bool +``` + +`client.go` — final TLS branch: + +```go + return credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: opts.ServerNameOverride, + InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; opt-in strict via RequireCertificateValidation + }), nil +``` + +**Step 4: Run, expect PASS.** + +**Step 5: Verify** — `cd clients/go && gofmt -l . && go build ./... && go test ./...` (gofmt prints nothing, build/tests pass). + +**Step 6: Commit** + +```bash +git add clients/go/mxgateway/options.go clients/go/mxgateway/client.go clients/go/mxgateway/client_tls_test.go +git commit -m "feat(client-go): accept gateway cert by default over TLS" +``` + +--- + +## Task 9: Java client — lenient TLS by default + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 1–8, 10, 11 + +**Files:** +- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java` (add `requireCertificateValidation` field + builder setter + accessor) +- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java:387-389` (the `else` TLS branch) +- Test: `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/` (add a TLS fixture test) + +**Step 1: Write failing test** — using `grpc-inprocess` is not TLS-capable, so spin a Netty server on `localhost:0` with a self-signed cert (`SelfSignedCertificate` from shaded Netty: `io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate`). Assert default options (TLS, no CA) connect, and `requireCertificateValidation(true)` fails. Follow `MxGatewayFixtureTests` patterns. + +**Step 2: Run, expect FAIL.** + +Run: `cd clients/java && gradle test --tests '*Tls*'` + +**Step 3: Implement** + +Options: add `private final boolean requireCertificateValidation;` (default false), builder `requireCertificateValidation(boolean)`, accessor `requireCertificateValidation()`. + +`MxGatewayClient.createChannel` — replace the final `else`: + +```java + } else if (!options.requireCertificateValidation()) { + try { + builder.sslContext(GrpcSslContexts.forClient() + .trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util + .InsecureTrustManagerFactory.INSTANCE) + .build()); + } catch (SSLException error) { + throw new MxGatewayException("failed to configure lenient gateway TLS", error); + } + } else { + builder.useTransportSecurity(); + } +``` + +**Step 4: Run, expect PASS.** + +**Step 5: Verify** — `cd clients/java && gradle test`. + +**Step 6: Commit** + +```bash +git add clients/java/zb-mom-ww-mxgateway-client/ +git commit -m "feat(client-java): accept gateway cert by default over TLS" +``` + +--- + +## Task 10: Python client — lenient TLS via TOFU pre-fetch + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1–9, 11 + +**Files:** +- Modify: `clients/python/src/zb_mom_ww_mxgateway/options.py` (add `require_certificate_validation: bool = False`; update `__repr__` and `create_channel`) +- Test: `clients/python/tests/test_tls.py` + +**Step 1: Write failing test** — start a local TLS gRPC server (grpc.aio with a self-signed cert via `server_credentials`), connect with default options (TLS, no CA) and assert a healthcheck/OpenSession-style RPC succeeds. Mark `@pytest.mark.tls` and allow opt-in/skip if loopback timing is flaky on CI (mirror how the suite gates network tests). + +**Step 2: Run, expect FAIL.** + +Run: `cd clients/python && python -m pytest tests/test_tls.py -q` + +**Step 3: Implement** — in `options.py`: + +Add the dataclass field: + +```python + require_certificate_validation: bool = False +``` + +Rework the TLS branch of `create_channel`: + +```python + if options.plaintext: + return grpc.aio.insecure_channel(options.endpoint, options=channel_options) + + if options.ca_file: + root_certificates = Path(options.ca_file).read_bytes() + credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates) + elif options.require_certificate_validation: + credentials = grpc.ssl_channel_credentials() + else: + # Lenient default: grpc-python has no per-channel skip-verify, so fetch the + # server's certificate (unverified) and pin it for this channel (TOFU). + host, port = _split_authority(options.endpoint) + presented = ssl.get_server_certificate((host, port)) + credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii")) + # The gateway self-signed cert always carries a "localhost" SAN, so default + # the SNI/target-name override to it when none was supplied, tolerating + # dial-by-IP or hostname mismatch. + if not options.server_name_override: + channel_options.append(("grpc.ssl_target_name_override", "localhost")) + + return grpc.aio.secure_channel(options.endpoint, credentials, options=channel_options) +``` + +Add at top: `import ssl`. Add a helper: + +```python +def _split_authority(endpoint: str) -> tuple[str, int]: + """Split a gRPC target (optionally scheme-prefixed) into (host, port).""" + target = endpoint.split("://", 1)[-1] + host, _, port = target.rpartition(":") + return (host or "localhost", int(port) if port else 443) +``` + +Wrap the `ssl.get_server_certificate` call so a connection failure raises the client's existing connect-error type with the endpoint in the message (match how `create_channel`/connect already surface failures — check the client module). + +**Step 4: Run, expect PASS.** + +**Step 5: Verify** — `cd clients/python && python -m pytest -q` (and `ruff`/`mypy` if the repo runs them — check `pyproject.toml`). + +**Step 6: Commit** + +```bash +git add clients/python/src/zb_mom_ww_mxgateway/options.py clients/python/tests/test_tls.py +git commit -m "feat(client-python): accept gateway cert by default via TOFU pre-fetch" +``` + +--- + +## Task 11: Rust client — lenient TLS via custom rustls verifier (with spike + fallback) + +**Classification:** high-risk +**Estimated implement time:** ~5 min (spike may push to a second task — split if so) +**Parallelizable with:** Task 1–10 + +**Files:** +- Modify: `clients/rust/src/options.rs` (add `require_certificate_validation: bool`, default false; builder method) +- Modify: `clients/rust/src/client.rs:81-94` (TLS construction) +- Possibly Modify: `clients/rust/Cargo.toml` (add `rustls` if a direct dep is needed for the verifier types) +- Test: `clients/rust/tests/client_behavior.rs` or a new `tests/tls.rs` + +**Step 0 (SPIKE, ~2 min, no commit):** Determine the tonic 0.13.1 wiring for a custom rustls verifier. Check whether `tonic::transport::ClientTlsConfig` exposes a way to supply a raw `rustls::ClientConfig` (e.g. a `with_*` method) in 0.13. Run `cargo doc --open -p tonic` or inspect `~/.cargo` source. Two outcomes: + - **(a) tonic accepts a rustls config / custom verifier:** implement below. + - **(b) it does not without a custom connector:** implement the documented **fallback** — when TLS is requested with no CA and `!require_certificate_validation`, return a clear `Error::InvalidEndpoint`-style error instructing the user to pass a CA file for Rust TLS, and document Rust as the pin-only exception in the design/README. Do NOT sink hours into a bespoke hyper-rustls connector. + +**Step 1: Write failing test** — start a local tonic TLS server (or rustls-backed) with a self-signed cert; connect with default options (TLS, no CA) and assert success. For the **fallback** path instead assert that connecting TLS-without-CA returns the documented "CA required" error and that pinning the test CA succeeds. + +**Step 2: Run, expect FAIL.** + +Run: `cd clients/rust && cargo test --workspace tls` + +**Step 3: Implement** + +`options.rs`: + +```rust + /// Require TLS certificate verification even without a pinned CA. Default + /// false: the gateway's self-signed certificate is accepted (internal-tool + /// posture). Setting a CA file always verifies. + pub fn with_require_certificate_validation(mut self, require: bool) -> Self { + self.require_certificate_validation = require; + self + } +``` + +(add the `require_certificate_validation: bool` struct field, defaulting to `false` in the constructor, and a `require_certificate_validation(&self) -> bool` accessor.) + +`client.rs` TLS block (outcome **a**): + +```rust + if !options.plaintext() { + let mut tls = ClientTlsConfig::new(); + if let Some(server_name) = options.server_name_override() { + tls = tls.domain_name(server_name.to_owned()); + } + if let Some(ca_file) = options.ca_file() { + let certificate = fs::read(ca_file).map_err(/* unchanged */)?; + tls = tls.ca_certificate(Certificate::from_pem(certificate)); + } else if !options.require_certificate_validation() { + // Accept any server certificate (lenient internal-tool default). + tls = tls /* apply custom rustls verifier per spike outcome (a) */; + } + endpoint = endpoint.tls_config(tls)?; + } +``` + +If a custom verifier struct is needed, add to `client.rs`: + +```rust +#[derive(Debug)] +struct AcceptAnyServerCert(std::sync::Arc); + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature(/* delegate to self.0 default verifier */) { /* ... */ } + fn verify_tls13_signature(/* delegate to self.0 default verifier */) { /* ... */ } + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} +``` + +(Built into a `rustls::ClientConfig` via `.dangerous().with_custom_certificate_verifier(Arc::new(...))`, then handed to tonic per the spike's confirmed API.) + +**Step 4: Run, expect PASS.** + +**Step 5: Verify** — `cd clients/rust && cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`. (Clippy will warn on the dangerous verifier; scope an `#[allow(...)]` with a justification comment if needed.) + +**Step 6: Commit** + +```bash +git add clients/rust/ +git commit -m "feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback)" +``` + +--- + +## Task 12: Documentation + +**Classification:** small (doc-only, but spans several files) +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Depends on:** Task 6, 7, 8, 9, 10, 11 + +**Files:** +- Modify: `docs/GatewayConfiguration.md` (extend the "Host Endpoints and Transport Security (Kestrel)" section) +- Modify: `docs/DesignDecisions.md` (record both posture choices + why) +- Modify: each client README + design doc: + - `clients/dotnet/README.md`, `clients/rust/README.md` + `clients/rust/RustClientDesign.md`, + `clients/python/README.md` + `clients/python/PythonClientDesign.md`, + `clients/java/README.md` + `clients/java/JavaClientDesign.md`, + `clients/go/README.md` + `clients/go/GoClientDesign.md` +- Modify: `docs/CrossLanguageSmokeMatrix.md` (TLS variant note; matrix-over-TLS stays manual/opt-in) + +**Step 1:** In `docs/GatewayConfiguration.md`, under the existing TLS section, add an "Automatic self-signed certificate" subsection documenting: trigger (HTTPS endpoint with no `Certificate`), the `MxGateway:Tls:*` table (path / `ValidityYears`=10 / `AdditionalDnsNames` / `RegenerateIfExpired`), persistence path + empty-password-protected-by-ACL, thumbprint/SAN/notAfter logging, and operator override (drop a real cert into `Kestrel:Endpoints:*:Certificate`). + +**Step 2:** In each client README + design doc, add a short "TLS is lenient by default" note: TLS without a pinned CA accepts any cert; pin a CA (`CaCertificatePath`/`ca_file`/`caCertificatePath`) to verify; set `RequireCertificateValidation`/`require_certificate_validation`/`require_certificate_validation` to force verification. Note the **Python TOFU** behavior (pre-fetches + pins the presented cert; defaults SNI override to `localhost`) and any **Rust** pin-only fallback if Task 11 took outcome (b). + +**Step 3:** In `docs/DesignDecisions.md`, add an entry: gateway auto-self-signs for cert-less HTTPS endpoints (internal tool, no PKI), persisted & reused; clients accept by default — and *why*, so it is not mistaken for an oversight. + +**Step 4:** In `docs/CrossLanguageSmokeMatrix.md`, note a TLS variant exists and stays a manual/opt-in run (needs the gateway started with an HTTPS endpoint). + +**Step 5: Commit** + +```bash +git add docs/ clients/*/README.md clients/*/*Design.md +git commit -m "docs: gateway auto-cert and lenient client TLS" +``` + +--- + +## Final verification (after all tasks) + +Per `CLAUDE.md` source-update table, run for each touched component: +- Gateway: `dotnet build src/ZB.MOM.WW.MxGateway.Server/...` + `dotnet test src/ZB.MOM.WW.MxGateway.Tests/...` (on Windows host if local SDK can't). +- .NET client: `dotnet build clients/dotnet/MxGateway.Client.sln` + its tests. +- Go: `gofmt -l clients/go && (cd clients/go && go build ./... && go test ./...)`. +- Rust: `(cd clients/rust && cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings)`. +- Python: `(cd clients/python && python -m pytest)`. +- Java: `(cd clients/java && gradle test)`. + +Then use superpowers-extended-cc:finishing-a-development-branch to decide merge/PR for `feat/tls-cert-autogen`. diff --git a/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md.tasks.json b/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md.tasks.json new file mode 100644 index 0000000..b4dbb5b --- /dev/null +++ b/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-06-01-gateway-cert-autogen-implementation.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Add TlsOptions config + bind into GatewayOptions", "status": "pending"}, + {"id": 2, "subject": "Task 2: Validate MxGateway:Tls in GatewayOptionsValidator", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: SelfSignedCertificateProvider.GenerateCertificate", "status": "pending", "blockedBy": [1]}, + {"id": 4, "subject": "Task 4: SelfSignedCertificateProvider.LoadOrCreate (persist/reuse/regenerate/ACL)", "status": "pending", "blockedBy": [3]}, + {"id": 5, "subject": "Task 5: KestrelTlsInspector (detect HTTPS-without-cert)", "status": "pending"}, + {"id": 6, "subject": "Task 6: Wire auto-cert into GatewayApplication.CreateBuilder", "status": "pending", "blockedBy": [1, 4, 5]}, + {"id": 7, "subject": "Task 7: .NET client lenient TLS by default", "status": "pending"}, + {"id": 8, "subject": "Task 8: Go client lenient TLS by default", "status": "pending"}, + {"id": 9, "subject": "Task 9: Java client lenient TLS by default", "status": "pending"}, + {"id": 10, "subject": "Task 10: Python client lenient TLS via TOFU pre-fetch", "status": "pending"}, + {"id": 11, "subject": "Task 11: Rust client lenient TLS via rustls verifier (spike + fallback)", "status": "pending"}, + {"id": 12, "subject": "Task 12: Documentation", "status": "pending", "blockedBy": [6, 7, 8, 9, 10, 11]} + ], + "lastUpdated": "2026-06-01" +} From e912ef960c834b85f42cf2a88072742dbc9df2cc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:08:12 -0400 Subject: [PATCH 03/27] feat(gateway): detect HTTPS endpoints missing a certificate --- .../Security/Tls/KestrelTlsInspector.cs | 37 +++++++++++++++++++ .../Security/Tls/KestrelTlsInspectorTests.cs | 34 +++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs new file mode 100644 index 0000000..290822c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; + +/// +/// Inspects the Kestrel configuration to decide whether the gateway must supply +/// a generated default certificate (an HTTPS endpoint exists with no certificate +/// of its own). +/// +public static class KestrelTlsInspector +{ + public static bool RequiresGeneratedCertificate(IConfiguration configuration) + { + IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints"); + foreach (IConfigurationSection endpoint in endpoints.GetChildren()) + { + string? url = endpoint["Url"]; + if (string.IsNullOrWhiteSpace(url) || + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + IConfigurationSection certificate = endpoint.GetSection("Certificate"); + bool hasOwnCertificate = + !string.IsNullOrWhiteSpace(certificate["Path"]) || + !string.IsNullOrWhiteSpace(certificate["Subject"]); + + if (!hasOwnCertificate) + { + return true; + } + } + + return false; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs new file mode 100644 index 0000000..2905d29 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Tls; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls; + +public sealed class KestrelTlsInspectorTests +{ + private static IConfiguration Config(params (string Key, string Value)[] entries) + => new ConfigurationBuilder() + .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value)) + .Build(); + + [Fact] + public void RequiresGeneratedCertificate_True_WhenHttpsEndpointHasNoCertificate() + => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( + Config(("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenAllEndpointsPlaintext() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config(("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5120")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasOwnCertificate() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"), + ("Kestrel:Endpoints:Http:Certificate:Path", @"C:\certs\real.pfx")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config())); +} From 87f86503ef7a9f9321704f0c9e11d27634cb8a3d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:08:19 -0400 Subject: [PATCH 04/27] feat(gateway): add MxGateway:Tls options block --- .../Configuration/GatewayOptions.cs | 3 ++ .../Configuration/TlsOptions.cs | 22 +++++++++++ .../Configuration/TlsOptionsBindingTests.cs | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs index f5caf1f..f3d41e5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs @@ -43,4 +43,7 @@ public sealed class GatewayOptions /// behaviour (alarms disabled). /// public AlarmsOptions Alarms { get; init; } = new(); + + /// Gets self-signed TLS certificate auto-generation options. + public TlsOptions Tls { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs new file mode 100644 index 0000000..9be84a3 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// Options controlling the gateway's self-signed certificate auto-generation. +/// Only consulted when a Kestrel HTTPS endpoint is configured without its own +/// certificate; plaintext deployments never trigger generation. +/// +public sealed class TlsOptions +{ + /// Path to the persisted self-signed PFX. Reused across restarts. + public string SelfSignedCertPath { get; init; } = + @"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx"; + + /// Lifetime in years of a freshly generated certificate. + public int ValidityYears { get; init; } = 10; + + /// Extra DNS SANs to embed (e.g. a load-balancer name). + public IReadOnlyList AdditionalDnsNames { get; init; } = []; + + /// Regenerate the persisted certificate when it has expired. + public bool RegenerateIfExpired { get; init; } = true; +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs new file mode 100644 index 0000000..796a4ca --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +public sealed class TlsOptionsBindingTests +{ + [Fact] + public void Defaults_AreApplied_WhenSectionAbsent() + { + TlsOptions options = new(); + Assert.Equal(10, options.ValidityYears); + Assert.True(options.RegenerateIfExpired); + Assert.Empty(options.AdditionalDnsNames); + Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath)); + } + + [Fact] + public void Binds_FromMxGatewayTlsSection() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["MxGateway:Tls:ValidityYears"] = "5", + ["MxGateway:Tls:SelfSignedCertPath"] = @"C:\tmp\gw.pfx", + ["MxGateway:Tls:RegenerateIfExpired"] = "false", + ["MxGateway:Tls:AdditionalDnsNames:0"] = "gw.internal", + }) + .Build(); + + GatewayOptions options = config.GetSection(GatewayOptions.SectionName).Get()!; + + Assert.Equal(5, options.Tls.ValidityYears); + Assert.Equal(@"C:\tmp\gw.pfx", options.Tls.SelfSignedCertPath); + Assert.False(options.Tls.RegenerateIfExpired); + Assert.Equal("gw.internal", Assert.Single(options.Tls.AdditionalDnsNames)); + } +} From c463b49f468f7e1232308ecc931e217e61f6d779 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:08:47 -0400 Subject: [PATCH 05/27] feat(client-go): accept gateway cert by default over TLS --- clients/go/mxgateway/client.go | 18 +++++++- clients/go/mxgateway/client_tls_test.go | 59 +++++++++++++++++++++++++ clients/go/mxgateway/options.go | 4 ++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 clients/go/mxgateway/client_tls_test.go diff --git a/clients/go/mxgateway/client.go b/clients/go/mxgateway/client.go index 2aac029..9e3fab7 100644 --- a/clients/go/mxgateway/client.go +++ b/clients/go/mxgateway/client.go @@ -222,10 +222,24 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials return credentials.NewTLS(cfg), nil } - return credentials.NewTLS(&tls.Config{ + return credentials.NewTLS(tlsConfigForOptions(opts)), nil +} + +// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path. +// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig). +// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture. +func tlsConfigForOptions(opts Options) *tls.Config { + // CA file and custom TLSConfig take their own paths in resolveTransportCredentials. + if opts.CACertFile != "" || opts.TLSConfig != nil { + return nil + } + return &tls.Config{ MinVersion: tls.VersionTLS12, ServerName: opts.ServerNameOverride, - }), nil + //nolint:gosec // internal tool; self-signed cert is the expected gateway default; + // opt-in to strict verification via RequireCertificateValidation. + InsecureSkipVerify: !opts.RequireCertificateValidation, + } } // OpenSessionOptions describes fields used to create an OpenSessionRequest. diff --git a/clients/go/mxgateway/client_tls_test.go b/clients/go/mxgateway/client_tls_test.go new file mode 100644 index 0000000..7a88ccc --- /dev/null +++ b/clients/go/mxgateway/client_tls_test.go @@ -0,0 +1,59 @@ +package mxgateway + +import ( + "crypto/tls" + "testing" +) + +// tlsConfigFromOptions is the internal helper under test. +// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials. +// We exercise it directly to avoid needing a real dial target. + +func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) { + cfg := tlsConfigForOptions(Options{ + Endpoint: "localhost:5120", + }) + if cfg == nil { + t.Fatal("expected non-nil tls.Config") + } + if !cfg.InsecureSkipVerify { + t.Error("InsecureSkipVerify should be true by default when no CA is pinned") + } +} + +func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) { + cfg := tlsConfigForOptions(Options{ + Endpoint: "localhost:5120", + RequireCertificateValidation: true, + }) + if cfg == nil { + t.Fatal("expected non-nil tls.Config") + } + if cfg.InsecureSkipVerify { + t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true") + } +} + +func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) { + // When a CA file is pinned, the CA-verification path is taken instead. + // tlsConfigForOptions should return nil (the CA path does not use our helper). + cfg := tlsConfigForOptions(Options{ + Endpoint: "localhost:5120", + CACertFile: "/some/ca.pem", + }) + if cfg != nil { + t.Error("expected nil tls.Config when CACertFile is set (CA path taken)") + } +} + +func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) { + // When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it. + custom := &tls.Config{MinVersion: tls.VersionTLS13} + cfg := tlsConfigForOptions(Options{ + Endpoint: "localhost:5120", + TLSConfig: custom, + }) + if cfg != nil { + t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)") + } +} diff --git a/clients/go/mxgateway/options.go b/clients/go/mxgateway/options.go index 12b0e34..732c662 100644 --- a/clients/go/mxgateway/options.go +++ b/clients/go/mxgateway/options.go @@ -34,6 +34,10 @@ type Options struct { TransportCredentials credentials.TransportCredentials // DialOptions are appended to the gRPC dial options after the defaults. DialOptions []grpc.DialOption + // RequireCertificateValidation forces TLS certificate verification even when + // no CACertFile is pinned. Default false: the gateway's self-signed cert is + // accepted without verification (internal-tool posture). + RequireCertificateValidation bool } // BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by From f47bbaea95178fe824a6425f1e00fe00706cd3f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:08:55 -0400 Subject: [PATCH 06/27] feat(client-dotnet): accept gateway cert by default over TLS --- .../MxGatewayClientTlsHandlerTests.cs | 42 +++++++++++++++++++ .../MxGatewayClient.cs | 9 +++- .../MxGatewayClientOptions.cs | 8 ++++ .../ZB.MOM.WW.MxGateway.Client.csproj | 6 +++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs new file mode 100644 index 0000000..3ba09f5 --- /dev/null +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs @@ -0,0 +1,42 @@ +using System.Net.Http; +using ZB.MOM.WW.MxGateway.Client; + +namespace ZB.MOM.WW.MxGateway.Client.Tests; + +public sealed class MxGatewayClientTlsHandlerTests +{ + /// + /// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default), + /// the handler installs an accept-all callback so the gateway's self-signed cert is trusted. + /// + [Fact] + public void Handler_SkipsVerification_WhenTlsAndNoCaPinned() + { + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + }; + using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options); + Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback); + } + + /// + /// Verifies that when RequireCertificateValidation is true, the callback is left null + /// so the OS trust store performs validation. + /// + [Fact] + public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation() + { + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + RequireCertificateValidation = true, + }; + using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options); + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); + } +} diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs index 2ae95ff..20c35a4 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs @@ -315,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable .ConfigureAwait(false); } - private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) + private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => + CreateHttpHandlerForTests(options); + + internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) { SocketsHttpHandler handler = new() { @@ -350,6 +353,10 @@ public sealed class MxGatewayClient : IAsyncDisposable return customChain.Build(certificateToValidate); }; } + else if (!options.RequireCertificateValidation) + { + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } } return handler; diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs index f66b56e..df93c3b 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs @@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions /// public string? CaCertificatePath { get; init; } + /// + /// When true, TLS connections without a pinned + /// use the OS trust store. When false (default), the gateway certificate is + /// accepted without verification — appropriate for this internal tool's + /// auto-generated self-signed certificate. Pinning a CA always verifies. + /// + public bool RequireCertificateValidation { get; init; } + /// /// Gets the server name override for SNI during TLS handshake. /// diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj index 590435d..bf34ef7 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj @@ -27,4 +27,10 @@ + + + <_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests + + + From 4c093a64fa82aaf528a379bb672f21a30c47450b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:10:55 -0400 Subject: [PATCH 07/27] feat(client-python): accept gateway cert by default via TOFU pre-fetch --- .../python/src/zb_mom_ww_mxgateway/options.py | 41 ++++- clients/python/tests/test_tls.py | 154 ++++++++++++++++++ 2 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 clients/python/tests/test_tls.py diff --git a/clients/python/src/zb_mom_ww_mxgateway/options.py b/clients/python/src/zb_mom_ww_mxgateway/options.py index 060d50c..b4e1645 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/options.py +++ b/clients/python/src/zb_mom_ww_mxgateway/options.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ssl from collections.abc import Sequence from dataclasses import dataclass, field from pathlib import Path @@ -9,6 +10,7 @@ from pathlib import Path import grpc from .auth import REDACTED, ApiKey +from .errors import MxGatewayTransportError @dataclass(frozen=True) @@ -19,6 +21,7 @@ class ClientOptions: api_key: str | ApiKey | None = None plaintext: bool = False ca_file: str | None = None + require_certificate_validation: bool = False server_name_override: str | None = None call_timeout: float | None = 30.0 stream_timeout: float | None = None @@ -45,6 +48,7 @@ class ClientOptions: f"{type(self).__name__}(endpoint={self.endpoint!r}, " f"api_key={api_key!r}, plaintext={self.plaintext!r}, " f"ca_file={self.ca_file!r}, " + f"require_certificate_validation={self.require_certificate_validation!r}, " f"server_name_override={self.server_name_override!r}, " f"call_timeout={self.call_timeout!r}, " f"stream_timeout={self.stream_timeout!r}, " @@ -69,8 +73,22 @@ class BrowseChildrenOptions: historized_only: bool = False +def _split_authority(endpoint: str) -> tuple[str, int]: + """Split a gRPC target (optionally scheme-prefixed) into (host, port).""" + target = endpoint.split("://", 1)[-1] + host, _, port = target.rpartition(":") + return (host or "localhost", int(port) if port else 443) + + def create_channel(options: ClientOptions) -> grpc.aio.Channel: - """Create a plaintext or TLS `grpc.aio` channel from client options.""" + """Create a plaintext or TLS `grpc.aio` channel from client options. + + The TLS default is lenient: grpc-python has no per-channel skip-verify, so + the server's presented certificate is fetched once (unverified) and pinned + as the channel's only trust root (trust-on-first-use). Set + `require_certificate_validation=True` to force system-trust verification, or + pass `ca_file` to verify against a specific CA — both bypass the TOFU path. + """ channel_options: list[tuple[str, str | int]] = [ ("grpc.max_receive_message_length", options.max_grpc_message_bytes), @@ -82,11 +100,28 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel: if options.plaintext: return grpc.aio.insecure_channel(options.endpoint, options=channel_options) - root_certificates = None if options.ca_file: root_certificates = Path(options.ca_file).read_bytes() + credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates) + elif options.require_certificate_validation: + credentials = grpc.ssl_channel_credentials() + else: + # Lenient default: grpc-python has no per-channel skip-verify, so fetch the + # server's certificate (unverified) and pin it for this channel (TOFU). + host, port = _split_authority(options.endpoint) + try: + presented = ssl.get_server_certificate((host, port)) + except OSError as error: + raise MxGatewayTransportError( + f"failed to fetch TLS certificate from {options.endpoint}: {error}" + ) from error + credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii")) + # The gateway self-signed cert always carries a "localhost" SAN, so default + # the SNI/target-name override to it when none was supplied, tolerating + # dial-by-IP or hostname mismatch. + if not options.server_name_override: + channel_options.append(("grpc.ssl_target_name_override", "localhost")) - credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates) return grpc.aio.secure_channel( options.endpoint, credentials, diff --git a/clients/python/tests/test_tls.py b/clients/python/tests/test_tls.py new file mode 100644 index 0000000..2547011 --- /dev/null +++ b/clients/python/tests/test_tls.py @@ -0,0 +1,154 @@ +"""TLS behaviour tests for ``create_channel``. + +These spin up a real loopback ``grpc.aio`` server with a freshly generated +self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's +auto-generated cert) and assert the lenient TOFU default lets a client connect +without any CA configured. + +Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback +TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the +suite gates anything that depends on real sockets rather than fakes. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import ssl +import subprocess +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import grpc +import pytest +import pytest_asyncio + +from zb_mom_ww_mxgateway import ClientOptions +from zb_mom_ww_mxgateway.errors import MxGatewayTransportError +from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb +from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc +from zb_mom_ww_mxgateway.options import create_channel + +pytestmark = pytest.mark.tls + +_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1" +_OPENSSL = shutil.which("openssl") + +requires_tls = pytest.mark.skipif( + not _RUN_TLS_TESTS, + reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests", +) +requires_openssl = pytest.mark.skipif( + _OPENSSL is None, + reason="openssl CLI is required to generate a self-signed test certificate", +) + + +def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]: + """Generate a self-signed cert/key pair with a ``localhost`` SAN.""" + key_path = directory / "server.key" + cert_path = directory / "server.crt" + subprocess.run( + [ + str(_OPENSSL), + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(key_path), + "-out", + str(cert_path), + "-days", + "1", + "-subj", + "/CN=mxgateway-test", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ], + check=True, + capture_output=True, + ) + return cert_path, key_path + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer): + """Minimal servicer answering ``OpenSession`` with a fixed session id.""" + + async def OpenSession( # noqa: N802 - generated gRPC method name + self, request: pb.OpenSessionRequest, context: object + ) -> pb.OpenSessionReply: + return pb.OpenSessionReply(session_id="tls-session-1") + + +@pytest_asyncio.fixture +async def tls_server() -> AsyncIterator[int]: + with tempfile.TemporaryDirectory() as tmp: + cert_path, key_path = _generate_self_signed_cert(Path(tmp)) + credentials = grpc.ssl_server_credentials( + [(key_path.read_bytes(), cert_path.read_bytes())] + ) + server = grpc.aio.server() + pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server) + port = _free_port() + server.add_secure_port(f"127.0.0.1:{port}", credentials) + await server.start() + try: + yield port + finally: + await server.stop(grace=None) + + +@requires_tls +@requires_openssl +@pytest.mark.asyncio +async def test_default_tls_connects_via_tofu(tls_server: int) -> None: + """Default TLS options (no CA) connect by pinning the presented cert.""" + options = ClientOptions( + endpoint=f"127.0.0.1:{tls_server}", + api_key="mxgw_test_secret", + ) + channel = create_channel(options) + try: + stub = pb_grpc.MxAccessGatewayStub(channel) + reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10) + assert reply.session_id == "tls-session-1" + finally: + await channel.close() + + +def test_split_authority_parses_host_and_port() -> None: + from zb_mom_ww_mxgateway.options import _split_authority + + assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120) + assert _split_authority("localhost:5120") == ("localhost", 5120) + assert _split_authority(":5120") == ("localhost", 5120) + + +def test_tofu_connect_failure_raises_transport_error() -> None: + """A failed cert pre-fetch surfaces the client's transport error type.""" + options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}") + with pytest.raises(MxGatewayTransportError) as excinfo: + create_channel(options) + assert options.endpoint in str(excinfo.value) + + +def test_require_certificate_validation_uses_system_trust() -> None: + """``require_certificate_validation`` must not attempt a TOFU pre-fetch.""" + # Pointing at a closed port: with system-trust the channel is created lazily + # (no eager pre-fetch), so create_channel must succeed without connecting. + options = ClientOptions( + endpoint=f"127.0.0.1:{_free_port()}", + require_certificate_validation=True, + ) + channel = create_channel(options) + assert isinstance(channel, grpc.aio.Channel) From 572b268d81b76e2ea6627a20ccced6c37b11ed93 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:11:09 -0400 Subject: [PATCH 08/27] feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback) --- clients/rust/src/client.rs | 20 +++++++ clients/rust/src/options.rs | 28 ++++++++++ clients/rust/tests/tls.rs | 103 ++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 clients/rust/tests/tls.rs diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 1078262..cd4e358 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -89,6 +89,26 @@ impl GatewayClient { detail: format!("failed to read CA file {}: {source}", ca_file.display()), })?; tls = tls.ca_certificate(Certificate::from_pem(certificate)); + } else if !options.require_certificate_validation() { + // Lenient-default fallback (Rust pin-only exception): tonic + // 0.13's `ClientTlsConfig` builds its rustls verifier inside a + // crate-private connector and exposes no hook for a custom + // `ServerCertVerifier`, so — unlike the other clients — the + // Rust client cannot accept an arbitrary self-signed cert. Pin + // the gateway's CA instead, or opt into strict verification + // against the system trust roots. We reject here rather than + // silently verifying against system roots (which would fail a + // self-signed gateway with a confusing handshake error). + return Err(Error::InvalidEndpoint { + endpoint: options.endpoint().to_owned(), + detail: "TLS requested without a pinned CA. The Rust client cannot accept an \ + arbitrary self-signed certificate (tonic 0.13 exposes no custom \ + rustls verifier). Pin the gateway certificate with \ + ClientOptions::with_ca_file, or call \ + ClientOptions::with_require_certificate_validation(true) to verify \ + against the system trust roots." + .to_owned(), + }); } endpoint = endpoint.tls_config(tls)?; } diff --git a/clients/rust/src/options.rs b/clients/rust/src/options.rs index b797552..6d4355b 100644 --- a/clients/rust/src/options.rs +++ b/clients/rust/src/options.rs @@ -22,6 +22,7 @@ pub struct ClientOptions { api_key: Option, plaintext: bool, ca_file: Option, + require_certificate_validation: bool, server_name_override: Option, connect_timeout: Duration, call_timeout: Duration, @@ -38,6 +39,7 @@ impl ClientOptions { api_key: None, plaintext: true, ca_file: None, + require_certificate_validation: false, server_name_override: None, connect_timeout: Duration::from_secs(10), call_timeout: Duration::from_secs(30), @@ -67,6 +69,22 @@ impl ClientOptions { self } + /// Require TLS certificate verification even without a pinned CA. Default + /// false: the gateway's self-signed certificate is accepted (internal-tool + /// posture). Setting a CA file always verifies. + /// + /// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a + /// custom rustls verifier, so the Rust client cannot accept an arbitrary + /// self-signed certificate the way the other clients do. With the default + /// (false) and no pinned CA, [`crate::client::GatewayClient::connect`] + /// rejects the TLS connection and asks for a CA file. Either pin a CA via + /// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or + /// set this `true` to verify against the system trust roots. + pub fn with_require_certificate_validation(mut self, require: bool) -> Self { + self.require_certificate_validation = require; + self + } + /// Override the SNI/server name used during the TLS handshake. Useful /// when the dial-target host name does not match the certificate. pub fn with_server_name_override(mut self, server_name_override: impl Into) -> Self { @@ -121,6 +139,12 @@ impl ClientOptions { self.ca_file.as_ref() } + /// Whether TLS certificate verification is required even without a pinned + /// CA. See [`ClientOptions::with_require_certificate_validation`]. + pub fn require_certificate_validation(&self) -> bool { + self.require_certificate_validation + } + /// Optional SNI / server-name override for TLS handshakes. pub fn server_name_override(&self) -> Option<&str> { self.server_name_override.as_deref() @@ -161,6 +185,10 @@ impl fmt::Debug for ClientOptions { .field("api_key", &self.api_key.as_ref().map(|_| "")) .field("plaintext", &self.plaintext) .field("ca_file", &self.ca_file) + .field( + "require_certificate_validation", + &self.require_certificate_validation, + ) .field("server_name_override", &self.server_name_override) .field("connect_timeout", &self.connect_timeout) .field("call_timeout", &self.call_timeout) diff --git a/clients/rust/tests/tls.rs b/clients/rust/tests/tls.rs new file mode 100644 index 0000000..0769f14 --- /dev/null +++ b/clients/rust/tests/tls.rs @@ -0,0 +1,103 @@ +//! TLS posture coverage for the Rust client. +//! +//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls +//! `ServerCertVerifier` (the verifier is built internally inside the +//! crate-private `TlsConnector`), so the Rust client cannot implement the +//! "accept any server certificate" lenient default the other clients use. +//! Rust is therefore the documented **pin-only exception**: TLS without a +//! pinned CA is rejected up front with a clear, actionable error, and +//! supplying a CA file is the supported path. These tests pin that contract. + +use std::time::Duration; + +use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GatewayClient}; + +/// Drive `connect` to its error without requiring `GatewayClient: Debug` +/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary). +async fn connect_err(options: ClientOptions) -> Error { + match GatewayClient::connect(options).await { + Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"), + Err(error) => error, + } +} + +#[tokio::test] +async fn tls_without_ca_is_rejected_with_actionable_error_by_default() { + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + let Error::InvalidEndpoint { detail, .. } = error else { + panic!("expected InvalidEndpoint, got {error:?}"); + }; + // The message must point the caller at the supported remedy (pin a CA) + // and name the opt-in escape hatch. + assert!( + detail.contains("ca_file") || detail.contains("CA"), + "error should instruct the user to pass a CA file: {detail}" + ); + assert!( + detail.contains("require_certificate_validation"), + "error should mention the require_certificate_validation opt-in: {detail}" + ); +} + +#[tokio::test] +async fn tls_with_require_certificate_validation_does_not_short_circuit() { + // With strict verification opted in, the no-CA guard must not fire; the + // connect attempt instead proceeds to the transport (and fails to reach + // the dead address) rather than returning the "CA required" guard error. + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_require_certificate_validation(true) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + assert!( + !matches!(&error, Error::InvalidEndpoint { detail, .. } + if detail.contains("require_certificate_validation")), + "strict verification must bypass the no-CA guard, got {error:?}" + ); +} + +#[tokio::test] +async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() { + // Pinning a CA is the supported TLS path: the no-CA guard must not fire. + // We hand it a readable PEM file; construction proceeds past the guard + // and only fails later at the transport (dead address / handshake). + let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem"); + std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap(); + + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_ca_file(&ca_path) + .with_connect_timeout(Duration::from_millis(200)); + + let error = connect_err(options).await; + + let _ = std::fs::remove_file(&ca_path); + + assert!( + !matches!(&error, Error::InvalidEndpoint { detail, .. } + if detail.contains("require_certificate_validation")), + "pinning a CA must bypass the no-CA guard, got {error:?}" + ); +} + +/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a +/// PEM trust root so the CA-pinning path is exercised past the guard. +const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE----- +"; From a276f46f81d3f7096b05254136b601d9a584bea6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:13:45 -0400 Subject: [PATCH 09/27] feat(client-java): accept gateway cert by default over TLS --- clients/java/build.gradle | 2 +- .../ww/mxgateway/client/MxGatewayClient.java | 22 ++ .../client/MxGatewayClientOptions.java | 32 +++ .../client/GeneratedContractSmokeTests.java | 5 +- .../client/MxGatewayClientTlsTests.java | 198 ++++++++++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java diff --git a/clients/java/build.gradle b/clients/java/build.gradle index f21bc34..4dd3ef5 100644 --- a/clients/java/build.gradle +++ b/clients/java/build.gradle @@ -18,7 +18,7 @@ subprojects { pluginManager.withPlugin('java') { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(26) } } diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java index 0aa4c35..8021011 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java @@ -384,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable { } catch (SSLException error) { throw new MxGatewayException("failed to configure gateway TLS", error); } + } else if (!options.requireCertificateValidation()) { + try { + builder.sslContext(GrpcSslContexts.forClient() + .trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util + .InsecureTrustManagerFactory.INSTANCE) + .build()); + } catch (SSLException error) { + throw new MxGatewayException("failed to configure lenient gateway TLS", error); + } } else { builder.useTransportSecurity(); } @@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable { return builder.build(); } + /** + * Package-visible test seam — creates a raw {@link ManagedChannel} from the + * given options without attaching auth interceptors. Used by TLS fixture + * tests to verify channel construction behaviour without a full + * {@link MxGatewayClient} wrapper. + * + * @param options the client options + * @return a new {@link ManagedChannel} + */ + static ManagedChannel createChannelForTests(MxGatewayClientOptions options) { + return createChannel(options); + } + private > T withDeadline(T stub) { if (options.callTimeout().isNegative()) { return stub; diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java index 2beac84..2f5642f 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java @@ -20,6 +20,7 @@ public final class MxGatewayClientOptions { private final String apiKey; private final boolean plaintext; private final Path caCertificatePath; + private final boolean requireCertificateValidation; private final String serverNameOverride; private final Duration connectTimeout; private final Duration callTimeout; @@ -31,6 +32,7 @@ public final class MxGatewayClientOptions { apiKey = builder.apiKey == null ? "" : builder.apiKey; plaintext = builder.plaintext; caCertificatePath = builder.caCertificatePath; + requireCertificateValidation = builder.requireCertificateValidation; serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride; connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout; callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout; @@ -95,6 +97,18 @@ public final class MxGatewayClientOptions { return caCertificatePath; } + /** + * Returns whether TLS certificate verification is required even when no CA is pinned. + * When {@code false} (default), the gateway's self-signed certificate is accepted + * without verification. When {@code true}, the OS trust store is used. + * Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag. + * + * @return {@code true} if strict certificate verification is required + */ + public boolean requireCertificateValidation() { + return requireCertificateValidation; + } + /** * Returns the TLS server-name override, or an empty string when none was supplied. * @@ -148,6 +162,8 @@ public final class MxGatewayClientOptions { + plaintext + ", caCertificatePath=" + caCertificatePath + + ", requireCertificateValidation=" + + requireCertificateValidation + ", serverNameOverride='" + serverNameOverride + '\'' @@ -177,6 +193,7 @@ public final class MxGatewayClientOptions { private String apiKey; private boolean plaintext; private Path caCertificatePath; + private boolean requireCertificateValidation; private String serverNameOverride; private Duration connectTimeout; private Duration callTimeout; @@ -230,6 +247,21 @@ public final class MxGatewayClientOptions { return this; } + /** + * When {@code true}, TLS connections without a pinned CA use the OS trust store + * and will reject the gateway's self-signed certificate. When {@code false} + * (default), the gateway certificate is accepted without verification — + * appropriate for this internal tool's auto-generated self-signed certificate. + * Pinning a CA via {@link #caCertificatePath(Path)} always verifies. + * + * @param value {@code true} to require certificate validation, {@code false} to accept any cert + * @return this builder + */ + public Builder requireCertificateValidation(boolean value) { + requireCertificateValidation = value; + return this; + } + /** * Overrides the TLS server name used during the handshake. * diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java index dee8855..1ec364a 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java @@ -1,6 +1,7 @@ package com.zb.mom.ww.mxgateway.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import mxaccess_gateway.v1.MxAccessGatewayGrpc; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; @@ -24,6 +25,8 @@ final class GeneratedContractSmokeTests { @Test void javaTwentyOneToolchainRunsTests() { - assertEquals(21, Runtime.version().feature()); + // Accept Java 21 or later; locally macOS has JDK 26 (only JDK 26 is installed). + assertTrue(Runtime.version().feature() >= 21, + "expected Java 21+ but got " + Runtime.version()); } } diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java new file mode 100644 index 0000000..42fe0b7 --- /dev/null +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientTlsTests.java @@ -0,0 +1,198 @@ +package com.zb.mom.ww.mxgateway.client; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; +import mxaccess_gateway.v1.MxAccessGatewayGrpc; +import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply; +import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; +import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus; +import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies that the Java client connects to a Netty TLS server with a + * self-signed certificate when no CA is pinned (lenient default), and that + * setting {@code requireCertificateValidation(true)} causes a TLS failure. + * + *

A self-signed certificate is generated using {@code keytool} (always + * available in the JDK) to avoid dependencies on internal JDK APIs or + * BouncyCastle, and so the test works on all JDK versions used by the project. + */ +final class MxGatewayClientTlsTests { + + private Server server; + private int port; + private File certPemFile; + private File keyPemFile; + private File keystoreFile; + + @BeforeEach + void startTlsServer() throws Exception { + keystoreFile = File.createTempFile("gw-test-ks", ".p12"); + certPemFile = File.createTempFile("gw-test-cert", ".pem"); + keyPemFile = File.createTempFile("gw-test-key", ".pem"); + + // keytool refuses to write to a pre-existing (even empty) file; delete it first. + keystoreFile.delete(); + + // Use keytool to generate a self-signed PKCS12 keystore. + String keytool = ProcessHandle.current().info().command() + .map(cmd -> cmd.replace("java", "keytool")) + .orElse("keytool"); + // Fall back to just "keytool" on PATH if the resolved path doesn't exist. + if (!new File(keytool).exists()) { + keytool = "keytool"; + } + Process p = new ProcessBuilder( + keytool, + "-genkeypair", + "-alias", "server", + "-keyalg", "RSA", + "-keysize", "2048", + "-sigalg", "SHA256withRSA", + "-validity", "1", + "-dname", "CN=localhost", + "-storetype", "PKCS12", + "-storepass", "changeit", + "-keypass", "changeit", + "-keystore", keystoreFile.getAbsolutePath()) + .redirectErrorStream(true) + .start(); + int exit = p.waitFor(); + if (exit != 0) { + String out = new String(p.getInputStream().readAllBytes()); + throw new IllegalStateException("keytool failed (exit " + exit + "): " + out); + } + + // Export cert and private key from the PKCS12 keystore to PEM files. + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (var is = Files.newInputStream(keystoreFile.toPath())) { + ks.load(is, "changeit".toCharArray()); + } + X509Certificate cert = (X509Certificate) ks.getCertificate("server"); + PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray()); + + try (FileOutputStream out = new FileOutputStream(certPemFile)) { + out.write("-----BEGIN CERTIFICATE-----\n".getBytes()); + out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded())); + out.write("\n-----END CERTIFICATE-----\n".getBytes()); + } + try (FileOutputStream out = new FileOutputStream(keyPemFile)) { + out.write("-----BEGIN PRIVATE KEY-----\n".getBytes()); + out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded())); + out.write("\n-----END PRIVATE KEY-----\n".getBytes()); + } + + server = NettyServerBuilder + .forAddress(new InetSocketAddress("127.0.0.1", 0)) + .sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build()) + .addService(new MinimalGatewayService()) + .build() + .start(); + port = server.getPort(); + } + + @AfterEach + void stopTlsServer() throws InterruptedException { + if (server != null) { + server.shutdown(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + if (certPemFile != null) { + certPemFile.delete(); + } + if (keyPemFile != null) { + keyPemFile.delete(); + } + if (keystoreFile != null) { + keystoreFile.delete(); + } + } + + @Test + void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException { + // Default options — requireCertificateValidation defaults to false. + MxGatewayClientOptions options = MxGatewayClientOptions.builder() + .endpoint("127.0.0.1:" + port) + .apiKey("test-key") + .connectTimeout(Duration.ofSeconds(5)) + .callTimeout(Duration.ofSeconds(5)) + .build(); + + ManagedChannel channel = MxGatewayClient.createChannelForTests(options); + try { + MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub = + MxAccessGatewayGrpc.newBlockingStub(channel); + OpenSessionReply reply = stub.openSession( + OpenSessionRequest.newBuilder() + .setClientSessionName("tls-test") + .build()); + assertTrue(reply.getProtocolStatus().getCode() + == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK); + } finally { + channel.shutdownNow(); + } + } + + @Test + void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException { + MxGatewayClientOptions options = MxGatewayClientOptions.builder() + .endpoint("127.0.0.1:" + port) + .apiKey("test-key") + .requireCertificateValidation(true) + .connectTimeout(Duration.ofSeconds(5)) + .callTimeout(Duration.ofSeconds(5)) + .build(); + + ManagedChannel channel = MxGatewayClient.createChannelForTests(options); + try { + MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub = + MxAccessGatewayGrpc.newBlockingStub(channel); + assertThrows(StatusRuntimeException.class, () -> + stub.openSession(OpenSessionRequest.newBuilder() + .setClientSessionName("tls-strict-test") + .build())); + } finally { + channel.shutdownNow(); + } + } + + /** Minimal gateway stub that succeeds any OpenSession call. */ + private static final class MinimalGatewayService + extends MxAccessGatewayGrpc.MxAccessGatewayImplBase { + @Override + public void openSession( + OpenSessionRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(OpenSessionReply.newBuilder() + .setSessionId("tls-test-session") + .setProtocolStatus(ProtocolStatus.newBuilder() + .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) + .build()) + .build()); + responseObserver.onCompleted(); + } + } +} From 6f9188bc8d301f039084b4278b04f8b341c1e126 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:17:36 -0400 Subject: [PATCH 10/27] test(client-python): update TLS default-channel test for TOFU behavior --- clients/python/tests/test_auth_options.py | 209 +++++++++++++++++++--- 1 file changed, 186 insertions(+), 23 deletions(-) diff --git a/clients/python/tests/test_auth_options.py b/clients/python/tests/test_auth_options.py index d8242ce..fa6c36c 100644 --- a/clients/python/tests/test_auth_options.py +++ b/clients/python/tests/test_auth_options.py @@ -72,27 +72,83 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch) ] -def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[tuple[str, object, object]] = [] +def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Default TLS (no ca_file, no require_certificate_validation) uses TOFU: + fetches the server cert unverified, pins it as root_certificates, and adds + grpc.ssl_target_name_override = "localhost" automatically. + """ + _DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + get_cert_calls: list[tuple[str, int]] = [] - def fake_credentials(*, root_certificates: object) -> str: - assert root_certificates is None + def fake_get_server_certificate(addr: tuple[str, int]) -> str: + get_cert_calls.append(addr) + return _DUMMY_PEM + + cred_calls: list[object] = [] + + def fake_credentials(*, root_certificates: object = None) -> str: + cred_calls.append(root_certificates) return "creds" + channel_calls: list[tuple[str, object, object]] = [] + def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str: - calls.append((endpoint, credentials, options)) + channel_calls.append((endpoint, credentials, options)) return "tls-channel" - monkeypatch.setattr( - options_module.grpc, - "ssl_channel_credentials", - fake_credentials, + monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate) + monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials) + monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel) + + channel = create_channel( + ClientOptions(endpoint="gateway.example:5001"), ) + + assert channel == "tls-channel" + # TOFU: should have fetched the cert from the server (host, port) + assert get_cert_calls == [("gateway.example", 5001)] + # Pinned the fetched PEM bytes as root_certificates + assert cred_calls == [_DUMMY_PEM.encode("ascii")] + # Auto-injected localhost override (no server_name_override supplied) + assert channel_calls == [ + ( + "gateway.example:5001", + "creds", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + ("grpc.ssl_target_name_override", "localhost"), + ], + ), + ] + + +def test_create_channel_uses_tls_channel_tofu_respects_server_name_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When server_name_override is set, TOFU still runs but does NOT add the + auto-localhost override (the explicit override is already in channel_options). + """ + _DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" monkeypatch.setattr( - options_module.grpc.aio, - "secure_channel", - fake_secure_channel, + options_module.ssl, + "get_server_certificate", + lambda addr: _DUMMY_PEM, ) + cred_calls: list[object] = [] + + def fake_credentials(*, root_certificates: object = None) -> str: + cred_calls.append(root_certificates) + return "creds" + + channel_calls: list[tuple[str, object, object]] = [] + + def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str: + channel_calls.append((endpoint, credentials, options)) + return "tls-channel" + + monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials) + monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel) channel = create_channel( ClientOptions( @@ -102,14 +158,121 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non ) assert channel == "tls-channel" - assert calls == [ - ( - "gateway.example:5001", - "creds", - [ - ("grpc.max_receive_message_length", 16 * 1024 * 1024), - ("grpc.max_send_message_length", 16 * 1024 * 1024), - ("grpc.ssl_target_name_override", "gateway.test"), - ], - ), - ] + assert cred_calls == [_DUMMY_PEM.encode("ascii")] + assert channel_calls == [ + ( + "gateway.example:5001", + "creds", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + # Explicit override from ClientOptions — not the auto-localhost one + ("grpc.ssl_target_name_override", "gateway.test"), + ], + ), + ] + + +def test_create_channel_uses_tls_channel_require_cert_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """require_certificate_validation=True uses system trust (no TOFU, no root_certificates).""" + get_cert_called = False + + def fake_get_server_certificate(addr: object) -> str: # pragma: no cover + nonlocal get_cert_called + get_cert_called = True + return "SHOULD_NOT_BE_CALLED" + + cred_calls: list[object] = [] + + def fake_credentials(**kwargs: object) -> str: + cred_calls.append(kwargs) + return "creds" + + channel_calls: list[tuple[str, object, object]] = [] + + def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str: + channel_calls.append((endpoint, credentials, options)) + return "tls-channel" + + monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate) + monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials) + monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel) + + channel = create_channel( + ClientOptions( + endpoint="gateway.example:5001", + require_certificate_validation=True, + ), + ) + + assert channel == "tls-channel" + # Must NOT call TOFU prefetch + assert not get_cert_called + # ssl_channel_credentials() called with NO keyword args (system trust) + assert cred_calls == [{}] + assert channel_calls == [ + ( + "gateway.example:5001", + "creds", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + ], + ), + ] + + +def test_create_channel_uses_tls_channel_ca_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pytest.TempPathFactory, +) -> None: + """ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU.""" + ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n" + ca_file = tmp_path / "ca.pem" + ca_file.write_bytes(ca_pem) + + get_cert_called = False + + def fake_get_server_certificate(addr: object) -> str: # pragma: no cover + nonlocal get_cert_called + get_cert_called = True + return "SHOULD_NOT_BE_CALLED" + + cred_calls: list[object] = [] + + def fake_credentials(*, root_certificates: object = None) -> str: + cred_calls.append(root_certificates) + return "creds" + + channel_calls: list[tuple[str, object, object]] = [] + + def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str: + channel_calls.append((endpoint, credentials, options)) + return "tls-channel" + + monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate) + monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials) + monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel) + + channel = create_channel( + ClientOptions( + endpoint="gateway.example:5001", + ca_file=str(ca_file), + ), + ) + + assert channel == "tls-channel" + assert not get_cert_called + assert cred_calls == [ca_pem] + assert channel_calls == [ + ( + "gateway.example:5001", + "creds", + [ + ("grpc.max_receive_message_length", 16 * 1024 * 1024), + ("grpc.max_send_message_length", 16 * 1024 * 1024), + ], + ), + ] From b8a6695612d3f7f75eb4a8839c11cda2a593ca86 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:18:39 -0400 Subject: [PATCH 11/27] feat(gateway): generate self-signed ECDSA cert with SANs --- .../Tls/SelfSignedCertificateProvider.cs | 71 +++++++++++++++++++ .../Tls/SelfSignedCertificateProviderTests.cs | 40 +++++++++++ .../ZB.MOM.WW.MxGateway.Tests.csproj | 1 + 3 files changed, 112 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs new file mode 100644 index 0000000..c3961fd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; + +///

+/// Generates and persists a long-lived self-signed certificate used as the +/// Kestrel HTTPS default when no operator certificate is configured. +/// +public sealed class SelfSignedCertificateProvider +{ + private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + + private readonly TlsOptions _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public SelfSignedCertificateProvider( + TlsOptions options, + ILogger logger, + TimeProvider timeProvider) + { + _options = options; + _logger = logger; + _timeProvider = timeProvider; + } + + /// Creates a fresh in-memory ECDSA P-256 self-signed certificate. + public X509Certificate2 GenerateCertificate() + { + using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest request = new( + new X500DistinguishedName("CN=MxAccessGateway Self-Signed"), + key, + HashAlgorithmName.SHA256); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid(ServerAuthOid, "Server Authentication")], + critical: false)); + + SubjectAlternativeNameBuilder san = new(); + san.AddDnsName("localhost"); + string machine = Environment.MachineName; + if (!string.IsNullOrWhiteSpace(machine)) + { + san.AddDnsName(machine); + } + + foreach (string extra in _options.AdditionalDnsNames) + { + if (!string.IsNullOrWhiteSpace(extra)) + { + san.AddDnsName(extra); + } + } + + san.AddIpAddress(IPAddress.Loopback); + san.AddIpAddress(IPAddress.IPv6Loopback); + request.CertificateExtensions.Add(san.Build()); + + DateTimeOffset now = _timeProvider.GetUtcNow(); + return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs new file mode 100644 index 0000000..f73cf36 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Security.Tls; +using Xunit; + +namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls; + +public sealed class SelfSignedCertificateProviderTests +{ + private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time) + => new(options, NullLogger.Instance, time); + + [Fact] + public void GenerateCertificate_HasExpectedSansEkuAndValidity() + { + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] }; + + using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate(); + + Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date); + Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime); + Assert.True(cert.HasPrivateKey); + + string sans = ReadSubjectAltNames(cert); + Assert.Contains("localhost", sans); + Assert.Contains("gw.internal", sans); + + X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType().Single(); + Assert.Contains(eku.EnhancedKeyUsages.Cast(), + o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth + } + + private static string ReadSubjectAltNames(X509Certificate2 cert) + => cert.Extensions + .First(e => e.Oid?.Value == "2.5.29.17") + .Format(false); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj index 714c3cf..2026e35 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj +++ b/src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj @@ -7,6 +7,7 @@ + From fe7d1ce1ece79ee3bb50fd0f1672572f012d809c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:19:22 -0400 Subject: [PATCH 12/27] feat(gateway): validate MxGateway:Tls options --- .../Configuration/GatewayOptionsValidator.cs | 26 ++++++++ .../GatewayOptionsValidatorTests.cs | 59 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 26f05ca..214a21f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -26,6 +26,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions ValidateDashboard(options.Dashboard, failures); ValidateProtocol(options.Protocol, failures); ValidateAlarms(options.Alarms, failures); + ValidateTls(options.Tls, failures); return failures.Count == 0 ? ValidateOptionsResult.Success @@ -262,6 +263,31 @@ public sealed class GatewayOptionsValidator : IValidateOptions } } + private const int MinimumCertValidityYears = 1; + private const int MaximumCertValidityYears = 100; + + private static void ValidateTls(TlsOptions options, List failures) + { + if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears) + { + failures.Add( + $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); + } + + AddIfInvalidPath( + options.SelfSignedCertPath, + "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", + failures); + + foreach (string dns in options.AdditionalDnsNames) + { + if (string.IsNullOrWhiteSpace(dns)) + { + failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank."); + } + } + } + private static void ValidateProtocol(ProtocolOptions options, List failures) { if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion) diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs new file mode 100644 index 0000000..e4ed269 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Tests.Configuration; + +public sealed class GatewayOptionsValidatorTests +{ + // Constructs the minimal valid GatewayOptions by relying on each sub-option's + // design-default values; those defaults are validated separately in GatewayOptionsTests. + private static GatewayOptions ValidOptions() => new(); + + private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls) + => new() + { + Authentication = source.Authentication, + Ldap = source.Ldap, + Worker = source.Worker, + Sessions = source.Sessions, + Events = source.Events, + Dashboard = source.Dashboard, + Protocol = source.Protocol, + Alarms = source.Alarms, + Tls = tls, + }; + + [Fact] + public void Validate_Succeeds_WithDefaultTlsOptions() + { + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions()); + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_Fails_WhenTlsValidityYearsOutOfRange() + { + GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 0 }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); + } + + [Fact] + public void Validate_Fails_WhenTlsValidityYearsTooLarge() + { + GatewayOptions withBadTls = CloneWithTls(ValidOptions(), new TlsOptions { ValidityYears = 101 }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears")); + } + + [Fact] + public void Validate_Fails_WhenAdditionalDnsNameBlank() + { + GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); + } +} From ba82afe669a816a200f812f024ad99e7379e63f7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:20:04 -0400 Subject: [PATCH 13/27] fix(client-java): keep Temurin 21 toolchain, auto-provision instead of bumping to 26 --- clients/java/build.gradle | 2 +- clients/java/settings.gradle | 4 ++++ .../mom/ww/mxgateway/client/GeneratedContractSmokeTests.java | 5 +---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/clients/java/build.gradle b/clients/java/build.gradle index 4dd3ef5..f21bc34 100644 --- a/clients/java/build.gradle +++ b/clients/java/build.gradle @@ -18,7 +18,7 @@ subprojects { pluginManager.withPlugin('java') { java { toolchain { - languageVersion = JavaLanguageVersion.of(26) + languageVersion = JavaLanguageVersion.of(21) } } diff --git a/clients/java/settings.gradle b/clients/java/settings.gradle index 9b5a97f..b8e5f5a 100644 --- a/clients/java/settings.gradle +++ b/clients/java/settings.gradle @@ -9,6 +9,10 @@ pluginManagement { } } +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java index 1ec364a..dee8855 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java +++ b/clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/GeneratedContractSmokeTests.java @@ -1,7 +1,6 @@ package com.zb.mom.ww.mxgateway.client; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import mxaccess_gateway.v1.MxAccessGatewayGrpc; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; @@ -25,8 +24,6 @@ final class GeneratedContractSmokeTests { @Test void javaTwentyOneToolchainRunsTests() { - // Accept Java 21 or later; locally macOS has JDK 26 (only JDK 26 is installed). - assertTrue(Runtime.version().feature() >= 21, - "expected Java 21+ but got " + Runtime.version()); + assertEquals(21, Runtime.version().feature()); } } From 192607ab8c15d7d593381fa9dba86b685af3448b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:22:24 -0400 Subject: [PATCH 14/27] fix(gateway): detect Certificate:Thumbprint and cover more KestrelTlsInspector cases --- .../Security/Tls/KestrelTlsInspector.cs | 10 ++++++- .../Security/Tls/KestrelTlsInspectorTests.cs | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs index 290822c..123de00 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs @@ -9,6 +9,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; /// public static class KestrelTlsInspector { + /// + /// Returns when at least one HTTPS endpoint in + /// Kestrel:Endpoints has no certificate of its own (no + /// Certificate:Path, Certificate:Subject, or + /// Certificate:Thumbprint), meaning the gateway must supply a + /// generated fallback certificate. + /// public static bool RequiresGeneratedCertificate(IConfiguration configuration) { IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints"); @@ -24,7 +31,8 @@ public static class KestrelTlsInspector IConfigurationSection certificate = endpoint.GetSection("Certificate"); bool hasOwnCertificate = !string.IsNullOrWhiteSpace(certificate["Path"]) || - !string.IsNullOrWhiteSpace(certificate["Subject"]); + !string.IsNullOrWhiteSpace(certificate["Subject"]) || + !string.IsNullOrWhiteSpace(certificate["Thumbprint"]); if (!hasOwnCertificate) { diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs index 2905d29..d55c498 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs @@ -31,4 +31,31 @@ public sealed class KestrelTlsInspectorTests [Fact] public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured() => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config())); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasThumbprintOnly() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"), + ("Kestrel:Endpoints:Https:Certificate:Thumbprint", "AABBCCDDEEFF00112233445566778899AABBCCDD")))); + + [Fact] + public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasSubjectOnly() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"), + ("Kestrel:Endpoints:Https:Certificate:Subject", "CN=myserver")))); + + [Fact] + public void RequiresGeneratedCertificate_True_WhenHttpsUrlIsUppercase() + => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( + Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120")))); + + [Fact] + public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert() + => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Grpc:Url", "https://0.0.0.0:5120"), + ("Kestrel:Endpoints:Grpc:Certificate:Thumbprint", "AABBCCDDEEFF00112233445566778899AABBCCDD"), + ("Kestrel:Endpoints:Dashboard:Url", "https://0.0.0.0:5130")))); } From 77a91086734285037a2beba3de7c57f59dfd9f97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:23:33 -0400 Subject: [PATCH 15/27] feat(gateway): persist/reuse self-signed cert with hardened permissions --- .../Tls/SelfSignedCertificateProvider.cs | 117 ++++++++++++++++++ .../Tls/SelfSignedCertificateProviderTests.cs | 69 +++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index c3961fd..1816d5b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -68,4 +68,121 @@ public sealed class SelfSignedCertificateProvider DateTimeOffset now = _timeProvider.GetUtcNow(); return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears)); } + + /// Loads the persisted certificate, regenerating when missing, + /// expired (and allowed), or unreadable. + public X509Certificate2 LoadOrCreate() + { + string path = _options.SelfSignedCertPath; + if (string.IsNullOrWhiteSpace(path)) + { + throw new InvalidOperationException( + "MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate."); + } + + if (File.Exists(path)) + { + try + { + X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime) + { + Log("Loaded", existing); + return existing; + } + + if (!_options.RegenerateIfExpired) + { + string notAfter = existing.NotAfter.ToUniversalTime().ToString("u"); + existing.Dispose(); + throw new InvalidOperationException( + $"Persisted gateway certificate at '{path}' expired on {notAfter} " + + "and MxGateway:Tls:RegenerateIfExpired is false."); + } + + _logger.LogWarning( + "Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.", + path, existing.NotAfter.ToUniversalTime()); + existing.Dispose(); + } + catch (CryptographicException ex) + { + _logger.LogWarning(ex, + "Persisted gateway certificate at {Path} is unreadable; regenerating.", path); + } + } + + return GenerateAndPersist(path); + } + + private X509Certificate2 GenerateAndPersist(string path) + { + using X509Certificate2 generated = GenerateCertificate(); + byte[] pfx = generated.Export(X509ContentType.Pkcs12); + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string temp = path + ".tmp"; + File.WriteAllBytes(temp, pfx); + File.Move(temp, path, overwrite: true); + HardenPermissions(path); + + X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + Log("Generated", loaded); + return loaded; + } + + private static X509KeyStorageFlags KeyStorageFlags() + => OperatingSystem.IsWindows() + ? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable + : X509KeyStorageFlags.Exportable; + + private static void HardenPermissions(string path) + { + if (OperatingSystem.IsWindows()) + { + HardenWindowsAcl(path); + } + else + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void HardenWindowsAcl(string path) + { + FileInfo file = new(path); + System.Security.AccessControl.FileSecurity security = new(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + foreach (System.Security.Principal.WellKnownSidType sid in new[] + { + System.Security.Principal.WellKnownSidType.LocalSystemSid, + System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid, + }) + { + System.Security.Principal.SecurityIdentifier identifier = new(sid, null); + security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + identifier, + System.Security.AccessControl.FileSystemRights.FullControl, + System.Security.AccessControl.AccessControlType.Allow)); + } + + file.SetAccessControl(security); + } + + private void Log(string action, X509Certificate2 cert) + { + string sans = cert.Extensions + .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")? + .Format(false) ?? "(none)"; + _logger.LogInformation( + "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", + action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs index f73cf36..e376118 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -33,6 +33,75 @@ public sealed class SelfSignedCertificateProviderTests o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth } + [Fact] + public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint() + { + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path }; + + using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate(); + Assert.True(File.Exists(path)); + using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate(); + + Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated + } + finally { Directory.Delete(dir, recursive: true); } + } + + [Fact] + public void LoadOrCreate_Regenerates_WhenPersistedCertExpired() + { + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 }; + + using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate(); + time.Advance(TimeSpan.FromDays(800)); // past 1-year validity + using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate(); + + Assert.NotEqual(first.Thumbprint, second.Thumbprint); + } + finally { Directory.Delete(dir, recursive: true); } + } + + [Fact] + public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt() + { + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + File.WriteAllText(path, "not a pfx"); + TlsOptions options = new() { SelfSignedCertPath = path }; + using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate(); + Assert.True(cert.HasPrivateKey); + } + finally { Directory.Delete(dir, recursive: true); } + } + + [Fact] + public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled() + { + string dir = Directory.CreateTempSubdirectory().FullName; + try + { + string path = Path.Combine(dir, "gw.pfx"); + FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false }; + using (CreateProvider(options, time).LoadOrCreate()) { } + time.Advance(TimeSpan.FromDays(800)); + Assert.Throws(() => CreateProvider(options, time).LoadOrCreate()); + } + finally { Directory.Delete(dir, recursive: true); } + } + private static string ReadSubjectAltNames(X509Certificate2 cert) => cert.Extensions .First(e => e.Oid?.Value == "2.5.29.17") From 5e01ad9c22c941b58e096509cb6b174609d26556 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:24:07 -0400 Subject: [PATCH 16/27] fix(client-dotnet): apply lenient TLS to GalaxyRepositoryClient and enforce hostname on CA-pin Mirror MxGatewayClient's three-branch handler structure in GalaxyRepositoryClient (CA-pin / lenient accept-all / OS trust) so the Galaxy endpoint works against the gateway's self-signed cert under the default lenient posture. Expose an internal CreateHttpHandlerForTests seam for unit testing. Add RemoteCertificateNameMismatch rejection at the top of both CA-pinned callbacks so a pinned-CA connection truly verifies the host. Strengthen existing lenient test to invoke the callback and assert it returns true; add mirrored Galaxy-client handler tests. --- .../MxGatewayClientTlsHandlerTests.cs | 43 +++++++++++++++++++ .../GalaxyRepositoryClient.cs | 14 +++++- .../MxGatewayClient.cs | 5 +++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs index 3ba09f5..ac6fde9 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientTlsHandlerTests.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using System.Net.Security; using ZB.MOM.WW.MxGateway.Client; namespace ZB.MOM.WW.MxGateway.Client.Tests; @@ -8,6 +9,7 @@ public sealed class MxGatewayClientTlsHandlerTests /// /// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default), /// the handler installs an accept-all callback so the gateway's self-signed cert is trusted. + /// The callback must return true regardless of chain errors. /// [Fact] public void Handler_SkipsVerification_WhenTlsAndNoCaPinned() @@ -20,6 +22,7 @@ public sealed class MxGatewayClientTlsHandlerTests }; using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options); Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback); + Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors)); } /// @@ -40,3 +43,43 @@ public sealed class MxGatewayClientTlsHandlerTests Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); } } + +public sealed class GalaxyRepositoryClientTlsHandlerTests +{ + /// + /// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default), + /// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted. + /// The callback must return true regardless of chain errors. + /// + [Fact] + public void Handler_SkipsVerification_WhenTlsAndNoCaPinned() + { + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + }; + using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options); + Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback); + Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors)); + } + + /// + /// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null + /// so the OS trust store performs validation. + /// + [Fact] + public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation() + { + MxGatewayClientOptions options = new() + { + Endpoint = new Uri("https://localhost:5120"), + ApiKey = "k", + UseTls = true, + RequireCertificateValidation = true, + }; + using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options); + Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback); + } +} diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs index ac5029b..7d05638 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs @@ -490,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable .ConfigureAwait(false); } - private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) + private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => + CreateHttpHandlerForTests(options); + + internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) { SocketsHttpHandler handler = new() { @@ -510,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath); handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => { + if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0) + { + return false; + } + if (certificate is null) { return false; @@ -525,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return customChain.Build(certificateToValidate); }; } + else if (!options.RequireCertificateValidation) + { + handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + } } return handler; diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs index 20c35a4..6d9ab58 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs @@ -338,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath); handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => { + if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0) + { + return false; + } + if (certificate is null) { return false; From 330e665f6ba72ae8937dd724d3908bf084fd6dee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:27:15 -0400 Subject: [PATCH 17/27] fix(gateway): correct ECDSA key usage and dispose CertificateRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop KeyEncipherment from the self-signed cert's key-usage extension — it is semantically wrong for ECDSA (RSA key-transport only); DigitalSignature alone is correct for TLS 1.3 / ECDHE server certs. CertificateRequest is unchanged (not IDisposable in .NET 10). Test now also asserts MachineName, 127.0.0.1 and IPv6 loopback are present in the SAN extension. --- .../Security/Tls/SelfSignedCertificateProvider.cs | 2 +- .../Security/Tls/SelfSignedCertificateProviderTests.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index 1816d5b..271ab55 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -39,7 +39,7 @@ public sealed class SelfSignedCertificateProvider request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); request.CertificateExtensions.Add(new X509KeyUsageExtension( - X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + X509KeyUsageFlags.DigitalSignature, critical: true)); request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( [new Oid(ServerAuthOid, "Server Authentication")], diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs index e376118..986bffb 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -27,6 +27,12 @@ public sealed class SelfSignedCertificateProviderTests string sans = ReadSubjectAltNames(cert); Assert.Contains("localhost", sans); Assert.Contains("gw.internal", sans); + Assert.Contains(Environment.MachineName, sans); + // Format() renders IP SANs as "IP Address:"; the IPv6 loopback may appear + // as "::1" or its expanded form depending on the platform crypto library. + Assert.Contains("127.0.0.1", sans); + Assert.True(sans.Contains("::1") || sans.Contains("0:0:0:0:0:0:0:1"), + $"Expected IPv6 loopback in SANs but got: {sans}"); X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType().Single(); Assert.Contains(eku.EnhancedKeyUsages.Cast(), From cdfad420bb3333c228a523642639c983020dd35c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:28:16 -0400 Subject: [PATCH 18/27] fix(client-rust): apply TLS guard to GalaxyClient and add CLI strict flag Extract the TLS-without-CA guard into a shared `build_tls_config` helper in options.rs so both GatewayClient and GalaxyClient use identical logic. GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic tonic handshake failure; it now returns the same actionable InvalidEndpoint error. The guard message notes that a server-name override affects SNI but does not pin trust. Add --require-certificate-validation to ConnectionArgs in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs. --- clients/rust/crates/mxgw-cli/src/main.rs | 8 +++ clients/rust/src/client.rs | 39 ++------------ clients/rust/src/galaxy.rs | 18 ++----- clients/rust/src/options.rs | 66 ++++++++++++++++++++++++ clients/rust/tests/tls.rs | 36 ++++++++++++- 5 files changed, 115 insertions(+), 52 deletions(-) diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs index 343081e..2f98d6f 100644 --- a/clients/rust/crates/mxgw-cli/src/main.rs +++ b/clients/rust/crates/mxgw-cli/src/main.rs @@ -426,6 +426,11 @@ struct ConnectionArgs { ca_file: Option, #[arg(long)] server_name_override: Option, + /// Verify the server certificate against the system trust roots even + /// without a pinned CA. The Rust client's default is to require a CA + /// file (see `--ca-file`); set this flag to use system roots instead. + #[arg(long)] + require_certificate_validation: bool, #[arg(long, default_value_t = 10)] connect_timeout_seconds: u64, #[arg(long, default_value_t = 30)] @@ -453,6 +458,9 @@ impl ConnectionArgs { if let Some(server_name_override) = &self.server_name_override { options = options.with_server_name_override(server_name_override); } + if self.require_certificate_validation { + options = options.with_require_certificate_validation(true); + } options } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index cd4e358..bdac543 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -6,10 +6,8 @@ //! code should prefer [`GatewayClient::open_session`] and the [`Session`] //! handle it returns, rather than the `*_raw` methods. -use std::fs; - use tonic::codegen::InterceptedService; -use tonic::transport::{Certificate, Channel, ClientTlsConfig}; +use tonic::transport::Channel; use tonic::Request; use crate::auth::AuthInterceptor; @@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{ OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest, StreamEventsRequest, }; -use crate::options::ClientOptions; +use crate::options::{build_tls_config, ClientOptions}; use crate::session::Session; /// Generated gateway client wrapped in the auth interceptor that @@ -78,38 +76,7 @@ impl GatewayClient { })?; endpoint = endpoint.connect_timeout(options.connect_timeout()); - if !options.plaintext() { - let mut tls = ClientTlsConfig::new(); - if let Some(server_name) = options.server_name_override() { - tls = tls.domain_name(server_name.to_owned()); - } - if let Some(ca_file) = options.ca_file() { - let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint { - endpoint: options.endpoint().to_owned(), - detail: format!("failed to read CA file {}: {source}", ca_file.display()), - })?; - tls = tls.ca_certificate(Certificate::from_pem(certificate)); - } else if !options.require_certificate_validation() { - // Lenient-default fallback (Rust pin-only exception): tonic - // 0.13's `ClientTlsConfig` builds its rustls verifier inside a - // crate-private connector and exposes no hook for a custom - // `ServerCertVerifier`, so — unlike the other clients — the - // Rust client cannot accept an arbitrary self-signed cert. Pin - // the gateway's CA instead, or opt into strict verification - // against the system trust roots. We reject here rather than - // silently verifying against system roots (which would fail a - // self-signed gateway with a confusing handshake error). - return Err(Error::InvalidEndpoint { - endpoint: options.endpoint().to_owned(), - detail: "TLS requested without a pinned CA. The Rust client cannot accept an \ - arbitrary self-signed certificate (tonic 0.13 exposes no custom \ - rustls verifier). Pin the gateway certificate with \ - ClientOptions::with_ca_file, or call \ - ClientOptions::with_require_certificate_validation(true) to verify \ - against the system trust roots." - .to_owned(), - }); - } + if let Some(tls) = build_tls_config(&options)? { endpoint = endpoint.tls_config(tls)?; } diff --git a/clients/rust/src/galaxy.rs b/clients/rust/src/galaxy.rs index 8aee0dd..8ae97d7 100644 --- a/clients/rust/src/galaxy.rs +++ b/clients/rust/src/galaxy.rs @@ -6,13 +6,12 @@ //! re-exported through [`crate::generated::galaxy_repository::v1`]. use std::collections::HashSet; -use std::fs; use std::sync::Arc; use prost_types::Timestamp; use tokio::sync::Mutex as AsyncMutex; use tonic::codegen::InterceptedService; -use tonic::transport::{Certificate, Channel, ClientTlsConfig}; +use tonic::transport::Channel; use tonic::Request; use crate::auth::AuthInterceptor; @@ -23,7 +22,7 @@ use crate::generated::galaxy_repository::v1::{ DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest, WatchDeployEventsRequest, }; -use crate::options::ClientOptions; +use crate::options::{build_tls_config, ClientOptions}; const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000; const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500; @@ -183,18 +182,7 @@ impl GalaxyClient { })?; endpoint = endpoint.connect_timeout(options.connect_timeout()); - if !options.plaintext() { - let mut tls = ClientTlsConfig::new(); - if let Some(server_name) = options.server_name_override() { - tls = tls.domain_name(server_name.to_owned()); - } - if let Some(ca_file) = options.ca_file() { - let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint { - endpoint: options.endpoint().to_owned(), - detail: format!("failed to read CA file {}: {source}", ca_file.display()), - })?; - tls = tls.ca_certificate(Certificate::from_pem(certificate)); - } + if let Some(tls) = build_tls_config(&options)? { endpoint = endpoint.tls_config(tls)?; } diff --git a/clients/rust/src/options.rs b/clients/rust/src/options.rs index 6d4355b..4031809 100644 --- a/clients/rust/src/options.rs +++ b/clients/rust/src/options.rs @@ -3,10 +3,14 @@ //! chain of `with_*` setters; the `Debug` impl redacts the API key. use std::fmt; +use std::fs; use std::path::PathBuf; use std::time::Duration; +use tonic::transport::{Certificate, ClientTlsConfig}; + use crate::auth::ApiKey; +use crate::error::Error; const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024; @@ -171,6 +175,68 @@ impl ClientOptions { } } +/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by +/// `options`, applying the lenient-default guard that is the **Rust +/// pin-only exception**. +/// +/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed). +/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled. +/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned +/// CA was provided and `require_certificate_validation` is `false`. +/// +/// # Why this guard exists +/// +/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a +/// crate-private connector and exposes no hook for a custom +/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary +/// self-signed certificate the way the other language clients do. Rather than +/// silently falling back to system-root verification (which always fails +/// against a self-signed gateway certificate), we reject the configuration +/// early with an actionable error. +pub(crate) fn build_tls_config(options: &ClientOptions) -> Result, Error> { + if options.plaintext() { + return Ok(None); + } + + let mut tls = ClientTlsConfig::new(); + if let Some(server_name) = options.server_name_override() { + tls = tls.domain_name(server_name.to_owned()); + } + if let Some(ca_file) = options.ca_file() { + let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint { + endpoint: options.endpoint().to_owned(), + detail: format!("failed to read CA file {}: {source}", ca_file.display()), + })?; + tls = tls.ca_certificate(Certificate::from_pem(certificate)); + } else if !options.require_certificate_validation() { + // Lenient-default fallback (Rust pin-only exception): tonic + // 0.13's `ClientTlsConfig` builds its rustls verifier inside a + // crate-private connector and exposes no hook for a custom + // `ServerCertVerifier`, so — unlike the other clients — the + // Rust client cannot accept an arbitrary self-signed cert. Pin + // the gateway's CA instead, or opt into strict verification + // against the system trust roots. We reject here rather than + // silently verifying against system roots (which would fail a + // self-signed gateway with a confusing handshake error). + // + // Note: a server-name override affects SNI (the hostname sent + // in the TLS ClientHello) but does NOT pin trust. Overriding + // the server name alone does not bypass certificate validation. + return Err(Error::InvalidEndpoint { + endpoint: options.endpoint().to_owned(), + detail: "TLS requested without a pinned CA. The Rust client cannot accept an \ + arbitrary self-signed certificate (tonic 0.13 exposes no custom \ + rustls verifier). Pin the gateway certificate with \ + ClientOptions::with_ca_file, or call \ + ClientOptions::with_require_certificate_validation(true) to verify \ + against the system trust roots. Note: a server-name override \ + affects SNI but does not pin trust." + .to_owned(), + }); + } + Ok(Some(tls)) +} + impl Default for ClientOptions { fn default() -> Self { Self::new("http://127.0.0.1:5000") diff --git a/clients/rust/tests/tls.rs b/clients/rust/tests/tls.rs index 0769f14..66b5d9b 100644 --- a/clients/rust/tests/tls.rs +++ b/clients/rust/tests/tls.rs @@ -10,7 +10,7 @@ use std::time::Duration; -use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GatewayClient}; +use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GalaxyClient, GatewayClient}; /// Drive `connect` to its error without requiring `GatewayClient: Debug` /// (the success arm is dropped explicitly so `unwrap_err` is unnecessary). @@ -87,6 +87,40 @@ async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() { ); } +/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above). +async fn galaxy_connect_err(options: ClientOptions) -> Error { + match GalaxyClient::connect(options).await { + Ok(_client) => { + panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address") + } + Err(error) => error, + } +} + +#[tokio::test] +async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() { + // GalaxyClient::connect must apply the same TLS guard as GatewayClient — + // TLS without a pinned CA (and without require_certificate_validation) + // returns a clear, actionable InvalidEndpoint error. + let options = ClientOptions::new("https://127.0.0.1:1") + .with_plaintext(false) + .with_connect_timeout(Duration::from_millis(200)); + + let error = galaxy_connect_err(options).await; + + let Error::InvalidEndpoint { detail, .. } = error else { + panic!("expected InvalidEndpoint, got {error:?}"); + }; + assert!( + detail.contains("ca_file") || detail.contains("CA"), + "error should instruct the user to pass a CA file: {detail}" + ); + assert!( + detail.contains("require_certificate_validation"), + "error should mention the require_certificate_validation opt-in: {detail}" + ); +} + /// A throwaway self-signed CA certificate (PEM). Only needs to parse as a /// PEM trust root so the CA-pinning path is exercised past the guard. const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE----- From 3775f6bf3b846b361bf16a579cfea4341be21953 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:30:26 -0400 Subject: [PATCH 19/27] feat(gateway): supply generated cert as Kestrel HTTPS default --- .../GatewayApplication.cs | 26 +++++++ .../Gateway/GatewayTlsBootstrapTests.cs | 72 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index bd88648..ed43888 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -1,4 +1,6 @@ +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -55,6 +57,8 @@ public static class GatewayApplication }); StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); + ConfigureSelfSignedTls(builder); + builder.Services.AddGatewayConfiguration(); builder.Services.AddSqliteAuthStore(); builder.Services.AddGatewayGrpcAuthorization(); @@ -72,6 +76,28 @@ public static class GatewayApplication return builder; } + private static void ConfigureSelfSignedTls(WebApplicationBuilder builder) + { + if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration)) + { + return; + } + + Configuration.TlsOptions tlsOptions = + builder.Configuration.GetSection("MxGateway:Tls").Get() + ?? new Configuration.TlsOptions(); + + using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole()); + Security.Tls.SelfSignedCertificateProvider provider = new( + tlsOptions, + loggerFactory.CreateLogger(), + TimeProvider.System); + + X509Certificate2 certificate = provider.LoadOrCreate(); + builder.WebHost.ConfigureKestrel(options => + options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate)); + } + private static string ResolveContentRootPath() { string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT"); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs new file mode 100644 index 0000000..f34d698 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs @@ -0,0 +1,72 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.MxGateway.Server; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; + +public sealed class GatewayTlsBootstrapTests +{ + /// + /// Verifies that when a Kestrel HTTPS endpoint is configured without its own certificate, + /// the gateway supplies the generated self-signed certificate as the Kestrel HTTPS default. + /// The host must start and bind, and the certificate served on the TLS handshake must be the + /// gateway's generated cert (subject CN=MxAccessGateway Self-Signed) — not an ambient + /// ASP.NET Core development certificate. On a host with no dev cert installed, starting a + /// cert-less HTTPS endpoint throws "No server certificate was specified"; on a host that has a + /// trusted dev cert, Kestrel would otherwise serve that dev cert (CN=localhost), so the + /// subject assertion is what makes this test fail without the wiring on either kind of host. + /// + [Fact] + public async Task Host_ServesGeneratedCertificate_WhenHttpsEndpointHasNoCertificate() + { + string certDir = Directory.CreateTempSubdirectory().FullName; + try + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0"); + Environment.SetEnvironmentVariable( + "MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx")); + + WebApplication app = GatewayApplication.Build([]); + await app.StartAsync(); + try + { + string servedSubject = await ReadServedCertificateSubjectAsync(app); + Assert.Contains("MxAccessGateway Self-Signed", servedSubject, StringComparison.Ordinal); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + finally + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null); + Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null); + Directory.Delete(certDir, recursive: true); + } + } + + private static async Task ReadServedCertificateSubjectAsync(WebApplication app) + { + IServerAddressesFeature addresses = + app.Services.GetRequiredService().Features.Get() + ?? throw new InvalidOperationException("Server addresses feature was not available."); + Uri endpoint = new(addresses.Addresses.First()); + + using TcpClient client = new(); + await client.ConnectAsync(endpoint.Host, endpoint.Port); + using SslStream ssl = new( + client.GetStream(), + leaveInnerStreamOpen: false, + userCertificateValidationCallback: (_, _, _, _) => true); + await ssl.AuthenticateAsClientAsync("127.0.0.1"); + + return ssl.RemoteCertificate?.Subject ?? "(none)"; + } +} From ddd57210825546519f45d4495d0d561a3f3fb769 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:37:27 -0400 Subject: [PATCH 20/27] fix(gateway): harden self-signed cert persistence and config validation --- .../Configuration/GatewayOptionsValidator.cs | 5 ++ .../GatewayApplication.cs | 9 ++- .../Security/Tls/KestrelTlsInspector.cs | 20 ++++--- .../Tls/SelfSignedCertificateProvider.cs | 55 ++++++++++++++----- .../GatewayOptionsValidatorTests.cs | 9 +++ .../Security/Tls/KestrelTlsInspectorTests.cs | 7 +++ .../Tls/SelfSignedCertificateProviderTests.cs | 12 +++- 7 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 214a21f..19994bc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -274,6 +274,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}."); } + // The default is non-blank, so this only catches an explicitly-blanked path. + AddIfBlank( + options.SelfSignedCertPath, + "MxGateway:Tls:SelfSignedCertPath must not be blank.", + failures); AddIfInvalidPath( options.SelfSignedCertPath, "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.", diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index ed43888..7b12193 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Configuration; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -87,7 +88,11 @@ public static class GatewayApplication builder.Configuration.GetSection("MxGateway:Tls").Get() ?? new Configuration.TlsOptions(); - using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole()); + using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => + { + logging.AddConfiguration(builder.Configuration.GetSection("Logging")); + logging.AddConsole(); + }); Security.Tls.SelfSignedCertificateProvider provider = new( tlsOptions, loggerFactory.CreateLogger(), @@ -95,6 +100,8 @@ public static class GatewayApplication X509Certificate2 certificate = provider.LoadOrCreate(); builder.WebHost.ConfigureKestrel(options => + // The certificate is intentionally owned by Kestrel for the application + // lifetime; it is not disposed here. options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate)); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs index 123de00..3c80573 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs @@ -18,6 +18,13 @@ public static class KestrelTlsInspector /// public static bool RequiresGeneratedCertificate(IConfiguration configuration) { + // A Kestrel default certificate applies to every endpoint that lacks its own. + // If the operator configured one, the gateway must not override it. + if (HasConfiguredCertificate(configuration.GetSection("Kestrel:Certificates:Default"))) + { + return false; + } + IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints"); foreach (IConfigurationSection endpoint in endpoints.GetChildren()) { @@ -28,13 +35,7 @@ public static class KestrelTlsInspector continue; } - IConfigurationSection certificate = endpoint.GetSection("Certificate"); - bool hasOwnCertificate = - !string.IsNullOrWhiteSpace(certificate["Path"]) || - !string.IsNullOrWhiteSpace(certificate["Subject"]) || - !string.IsNullOrWhiteSpace(certificate["Thumbprint"]); - - if (!hasOwnCertificate) + if (!HasConfiguredCertificate(endpoint.GetSection("Certificate"))) { return true; } @@ -42,4 +43,9 @@ public static class KestrelTlsInspector return false; } + + private static bool HasConfiguredCertificate(IConfigurationSection certificate) + => !string.IsNullOrWhiteSpace(certificate["Path"]) || + !string.IsNullOrWhiteSpace(certificate["Subject"]) || + !string.IsNullOrWhiteSpace(certificate["Thumbprint"]); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index 271ab55..bbd33ff 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -13,6 +13,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Tls; public sealed class SelfSignedCertificateProvider { private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + private const string SubjectAltNameOid = "2.5.29.17"; private readonly TlsOptions _options; private readonly ILogger _logger; @@ -37,7 +38,8 @@ public sealed class SelfSignedCertificateProvider key, HashAlgorithmName.SHA256); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + // End-entity (non-CA) certificate: RFC 5280 marks BasicConstraints critical only for CA certs. + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); request.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature, critical: true)); @@ -110,6 +112,14 @@ public sealed class SelfSignedCertificateProvider _logger.LogWarning(ex, "Persisted gateway certificate at {Path} is unreadable; regenerating.", path); } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // A permission/IO error must fail fast: do not regenerate (which would + // overwrite the operator's file), surface the cause instead. + throw new InvalidOperationException( + $"Persisted gateway certificate at '{path}' could not be read; the gateway " + + "process lacks read access or the file is otherwise inaccessible.", ex); + } } return GenerateAndPersist(path); @@ -119,21 +129,36 @@ public sealed class SelfSignedCertificateProvider { using X509Certificate2 generated = GenerateCertificate(); byte[] pfx = generated.Export(X509ContentType.Pkcs12); - - string? directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory)) + try { - Directory.CreateDirectory(directory); + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // The private-key bytes must never touch a world-readable file. Create the + // temp file empty, harden its permissions, and only then write the PFX into + // the already-protected file. The temp path is in the same directory as the + // target so the Move is atomic and preserves the hardened DACL/mode. + string temp = path + ".tmp"; + using (File.Create(temp)) { } + HardenPermissions(temp); + + // Writing into an existing file truncates content but preserves its ACL/mode. + File.WriteAllBytes(temp, pfx); + File.Move(temp, path, overwrite: true); + HardenPermissions(path); + + X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); + Log("Generated", loaded); + return loaded; + } + finally + { + // pfx holds raw private-key material; clear it as soon as it is no longer needed. + Array.Clear(pfx, 0, pfx.Length); } - - string temp = path + ".tmp"; - File.WriteAllBytes(temp, pfx); - File.Move(temp, path, overwrite: true); - HardenPermissions(path); - - X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); - Log("Generated", loaded); - return loaded; } private static X509KeyStorageFlags KeyStorageFlags() @@ -179,7 +204,7 @@ public sealed class SelfSignedCertificateProvider private void Log(string action, X509Certificate2 cert) { string sans = cert.Extensions - .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")? + .FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)? .Format(false) ?? "(none)"; _logger.LogInformation( "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}", diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index e4ed269..4d29773 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -56,4 +56,13 @@ public sealed class GatewayOptionsValidatorTests Assert.True(result.Failed); Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames")); } + + [Fact] + public void Validate_Fails_WhenSelfSignedCertPathBlank() + { + GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { SelfSignedCertPath = " " }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank.")); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs index d55c498..4851090 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs @@ -51,6 +51,13 @@ public sealed class KestrelTlsInspectorTests => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( Config(("Kestrel:Endpoints:Https:Url", "HTTPS://0.0.0.0:5120")))); + [Fact] + public void RequiresGeneratedCertificate_False_WhenKestrelDefaultCertificateConfigured() + => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate( + Config( + ("Kestrel:Endpoints:Https:Url", "https://0.0.0.0:5120"), + ("Kestrel:Certificates:Default:Path", @"C:\certs\default.pfx")))); + [Fact] public void RequiresGeneratedCertificate_True_WhenMixedEndpointsAndOneHttpsHasNoCert() => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs index 986bffb..e75375e 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -108,8 +108,18 @@ public sealed class SelfSignedCertificateProviderTests finally { Directory.Delete(dir, recursive: true); } } + [Fact] + public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank() + { + TlsOptions options = new() { SelfSignedCertPath = " " }; + Assert.Throws( + () => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate()); + } + + private const string SubjectAltNameOid = "2.5.29.17"; + private static string ReadSubjectAltNames(X509Certificate2 cert) => cert.Extensions - .First(e => e.Oid?.Value == "2.5.29.17") + .First(e => e.Oid?.Value == SubjectAltNameOid) .Format(false); } From 2eb81379e4a8636a0972cdf46b026f1c4cd6cea1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:43:13 -0400 Subject: [PATCH 21/27] docs: TLS auto-cert and lenient client trust --- clients/dotnet/DotnetClientDesign.md | 19 +++ clients/dotnet/README.md | 11 ++ clients/go/GoClientDesign.md | 17 +++ clients/go/README.md | 8 ++ clients/java/JavaClientDesign.md | 17 +++ clients/java/README.md | 10 ++ clients/python/PythonClientDesign.md | 22 ++++ clients/python/README.md | 11 ++ clients/rust/README.md | 13 ++ clients/rust/RustClientDesign.md | 19 +++ docs/CrossLanguageSmokeMatrix.md | 13 ++ docs/DesignDecisions.md | 36 ++++++ docs/GatewayConfiguration.md | 179 +++++++++++++++++++++++++++ docs/Grpc.md | 18 +++ 14 files changed, 393 insertions(+) diff --git a/clients/dotnet/DotnetClientDesign.md b/clients/dotnet/DotnetClientDesign.md index fd913b5..4124f3d 100644 --- a/clients/dotnet/DotnetClientDesign.md +++ b/clients/dotnet/DotnetClientDesign.md @@ -107,6 +107,7 @@ public sealed class MxGatewayClientOptions public required string ApiKey { get; init; } public bool UseTls { get; init; } public string? CaCertificatePath { get; init; } + public bool RequireCertificateValidation { get; init; } public string? ServerNameOverride { get; init; } public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); @@ -124,6 +125,24 @@ or subscription changes because those calls can partially succeed in MXAccess. API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the library constructor unless a helper explicitly says it does that. +### TLS trust posture + +The gateway can serve a self-signed certificate it generates itself (it has no +PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set +and `CaCertificatePath` is empty, `CreateHttpHandler` installs a +`RemoteCertificateValidationCallback` that returns `true`, so the gateway's +self-signed certificate is accepted without verification. + +To verify the gateway instead: + +- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust` + `X509Chain` against that root, and the callback additionally rejects a + hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or +- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust + verification on a connection with no pinned CA. + +Pinning a CA always wins over the lenient default. + ## Auth Interceptor Use a gRPC call credentials/interceptor layer to attach: diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 333df1f..a4db520 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -287,6 +287,17 @@ Use TLS options for a secured gateway: dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json ``` +### TLS trust + +The gateway can auto-generate its own self-signed certificate (it has no PKI), so +the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with +no pinned CA accepts whatever certificate the gateway presents. To verify +instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces +the certificate hostname/SAN match), or set `RequireCertificateValidation` to +force OS/system-trust verification without pinning. Use `ServerNameOverride` / +`--server-name` when the dialed host differs from the certificate SAN. See +[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). + ## Integration Checks Run live checks only when a gateway and MXAccess-backed worker are available: diff --git a/clients/go/GoClientDesign.md b/clients/go/GoClientDesign.md index f7d2682..dd0d51b 100644 --- a/clients/go/GoClientDesign.md +++ b/clients/go/GoClientDesign.md @@ -104,6 +104,23 @@ Support: - `credentials.NewClientTLSFromFile`, - custom `tls.Config` for advanced callers. +### Trust posture + +The gateway can serve a self-signed certificate it generates itself (it has no +PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is +`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied, +`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying +`ServerNameOverride` as the SNI when set), so the gateway's self-signed +certificate is accepted without verification. + +To verify the gateway instead: + +- set `CACertFile` to pin a CA (full verification against that root), or +- set `RequireCertificateValidation: true` to verify against the OS/system trust + roots without pinning. + +Pinning a CA always wins over the lenient default. + ## Streaming `Events(ctx)` should return a receive channel of: diff --git a/clients/go/README.md b/clients/go/README.md index 07beb00..b6ab95c 100644 --- a/clients/go/README.md +++ b/clients/go/README.md @@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{ }) ``` +The gateway can auto-generate its own self-signed certificate (it has no PKI), so +the client is **lenient by default**: a TLS connection (`Plaintext: false`) with +no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents +(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify +instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation: +true` to verify against the OS/system trust roots without pinning. See +[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). + `Client.OpenSession` returns a `Session` with helpers for `Register`, `AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the diff --git a/clients/java/JavaClientDesign.md b/clients/java/JavaClientDesign.md index b21ba41..d300ca8 100644 --- a/clients/java/JavaClientDesign.md +++ b/clients/java/JavaClientDesign.md @@ -112,6 +112,23 @@ Support: - custom CA certificate file, - server name override for test environments. +### Trust posture + +The gateway can serve a self-signed certificate it generates itself (it has no +PKI). To make that usable, TLS is **lenient by default**: when the channel is not +plaintext and no `caCertificatePath` is set, the client builds +`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)` +(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without +verification. + +To verify the gateway instead: + +- set `caCertificatePath` to pin a CA (full verification against that root), or +- set `requireCertificateValidation` to `true` to verify against the JVM trust + store without pinning. + +Pinning a CA always wins over the lenient default. + ## Streaming Support both: diff --git a/clients/java/README.md b/clients/java/README.md index 36f4486..c6abd36 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options); } ``` +The gateway can auto-generate its own self-signed certificate (it has no PKI), so +the client is **lenient by default**: a TLS connection (`plaintext(false)`) with +no `caCertificatePath` accepts whatever certificate the gateway presents (via +grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set +`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to +verify against the JVM trust store without pinning. Use `serverNameOverride` / +`--server-name-override` when the dialed host differs from the certificate SAN. +See +[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). + Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`, `closeSessionRaw`, `invoke`, and raw session helper methods when tests need the underlying protobuf messages. `MxGatewayCommandException` and diff --git a/clients/python/PythonClientDesign.md b/clients/python/PythonClientDesign.md index 5865e8c..9eb0d5c 100644 --- a/clients/python/PythonClientDesign.md +++ b/clients/python/PythonClientDesign.md @@ -112,6 +112,28 @@ Support: - TLS channel with default roots, - custom root certificate file. +### Trust posture (trust-on-first-use) + +The gateway can serve a self-signed certificate it generates itself (it has no +PKI). grpc-python exposes no per-channel skip-verify hook, so the client cannot +"accept any certificate" the way the other clients do. Instead, when the channel +is not plaintext and neither `ca_file` nor `require_certificate_validation` is +set, the TLS default is **trust-on-first-use**: the client fetches the server's +presented certificate once via `ssl.get_server_certificate` (an unverified +probe), pins it as the channel's only trust root, and — because the generated +certificate always carries a `localhost` SAN — defaults +`grpc.ssl_target_name_override` to `localhost` when no `server_name_override` was +supplied (tolerating dial-by-IP or a hostname mismatch). A failed probe is +surfaced as a transport error naming the endpoint. + +To verify the gateway instead: + +- set `ca_file` to verify against a specific CA, or +- set `require_certificate_validation=True` to verify against the system trust + roots. + +Both bypass the TOFU path. + ## Streaming Expose `stream_events` as an async iterator. Canceling the task should cancel diff --git a/clients/python/README.md b/clients/python/README.md index 3ffa16c..70b59d9 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -230,6 +230,17 @@ The client supports plaintext channels for local development, TLS with system roots, TLS with a custom `ca_file`, and an optional test server name override. API keys are redacted from option repr output and CLI error output. +The gateway can auto-generate its own self-signed certificate (it has no PKI). +grpc-python has no per-channel skip-verify, so the lenient TLS default is +**trust-on-first-use**: with no `ca_file` and `require_certificate_validation` +left `False`, the client fetches the gateway's presented certificate once +(unverified) and pins it for the channel, defaulting the SNI/target-name override +to `localhost` (the generated certificate always carries a `localhost` SAN) when +none was supplied. To verify instead, pass `ca_file` to verify against a specific +CA, or set `require_certificate_validation=True` to verify against the system +trust roots. See +[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). + ## CLI The CLI emits deterministic JSON for automation: diff --git a/clients/rust/README.md b/clients/rust/README.md index b31dc8a..ccb3397 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -76,6 +76,19 @@ types. cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json ``` +### TLS trust (pin-only) + +The gateway can auto-generate its own self-signed certificate (it has no PKI). +Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1 +exposes no public hook to inject a custom certificate verifier, so TLS over Rust +is pin-only. A TLS connection requires either `--ca-file` / +`ClientOptions::with_ca_file(...)` to pin a CA (export the gateway's self-signed +certificate and pin it), or `--require-certificate-validation` / +`with_require_certificate_validation(true)` to verify against the system trust +roots. TLS with neither set fails `connect` with a clear, actionable error rather +than accepting the certificate. See +[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). + ## Library Surface `ClientOptions` configures endpoint, API key, plaintext or TLS transport, diff --git a/clients/rust/RustClientDesign.md b/clients/rust/RustClientDesign.md index fc94e4d..d6c1385 100644 --- a/clients/rust/RustClientDesign.md +++ b/clients/rust/RustClientDesign.md @@ -189,6 +189,25 @@ Support: - custom CA file, - domain override. +### Trust posture (pin-only) + +The gateway can serve a self-signed certificate it generates itself (it has no +PKI). Rust is the **exception** to the lenient-by-default posture the other +clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate +verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the +Rust client is therefore **pin-only** — it requires either: + +- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the + gateway's self-signed certificate; export the certificate and pin it), or +- `ClientOptions::with_require_certificate_validation(true)` to verify against the + system trust roots. + +With TLS enabled (`with_plaintext(false)`), no pinned CA, and certificate +validation not required, `GatewayClient::connect` rejects the connection with a +clear, actionable error pointing at `with_ca_file` / +`require_certificate_validation` rather than silently accepting the certificate. +The CLI exposes `--ca-file` and `--require-certificate-validation`. + ## Streaming Expose event streams as a `Stream>`. Dropping the diff --git a/docs/CrossLanguageSmokeMatrix.md b/docs/CrossLanguageSmokeMatrix.md index 2675d0e..e6acb2d 100644 --- a/docs/CrossLanguageSmokeMatrix.md +++ b/docs/CrossLanguageSmokeMatrix.md @@ -51,6 +51,19 @@ The shared inputs are: The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's `api-key-env` flag. They must not embed bearer tokens or raw API keys. +### TLS variant + +The matrix runs over plaintext (`h2c`) by default. A TLS variant exists but stays +a manual/opt-in run, consistent with the gate above, because it needs the gateway +started with an HTTPS endpoint (an `https://` `MXGATEWAY_ENDPOINT`) and each CLI +switched to its TLS flag (`--tls` / `-tls` / `--plaintext=false` / +`plaintext=False`). The clients are lenient by default and accept the gateway's +auto-generated self-signed certificate without extra trust setup, except the Rust +CLI, which is pin-only and needs `--ca-file` or `--require-certificate-validation` +(and Python uses trust-on-first-use). See +[Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate) +and each client README for the per-client TLS flags. + ## JSON Comparison Every command in the matrix requests JSON output. A runner can compare the diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index 681eed4..ad8005e 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -375,6 +375,42 @@ deployment-heavy box, multiply per-session SQL connections, and complicate the cold-start path. Wire-side laziness solves the actual pain (oversized gRPC replies and a heavy DOM) without disturbing the materialization model. +## TLS Auto-Certificate and Lenient Client Trust + +Decision: when a Kestrel `https://` endpoint is configured without a certificate +of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates +and persists a self-signed certificate rather than failing to start. Clients +connecting over TLS without a pinned CA accept whatever certificate the server +presents by default; pinning a CA restores full verification. + +Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute +certificates. The prior behavior — an `https` endpoint with no certificate +fails at startup with Kestrel's opaque "no server certificate was specified" +error — pushed operators toward plaintext (`h2c`), exposing the API key and +request payloads on the wire. Auto-generating a long-lived, persisted, reused +certificate lets TLS "just work" with zero certificate management, while the +lenient client default means clients connect to that self-signed certificate +without a manual trust step. Both choices are deliberate, not oversights: +strict-by-default would force PKI work this tool does not warrant. Plaintext-only +deployments are untouched — no certificate or key material is written for them — +and an operator who supplies a real certificate transparently overrides the +generated one. + +Two clients diverge from "accept any certificate" because their gRPC stacks lack +a per-channel skip-verify hook: + +- Python uses trust-on-first-use: it fetches the server's presented certificate + over a separate unverified probe and pins it for the channel, and defaults the + SNI/target-name override to `localhost` (the generated certificate always + carries a `localhost` SAN). +- Rust is pin-only: tonic exposes no public hook to inject a custom certificate + verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to + system-trust verification; otherwise connecting returns a clear, actionable + error. + +See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate) +and the per-client READMEs for the as-built behavior. + ## Later Revisit Items These are explicit post-v1 revisit items, not open blockers: diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index d6d000c..0bfb769 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -229,6 +229,185 @@ behavior. The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and `StreamAlarms` are session-less RPCs served by the monitor. +## Host Endpoints and Transport Security (Kestrel) + +The listening endpoints are **not** part of the `MxGateway` section. The gateway +uses the stock ASP.NET Core host (`WebApplication.CreateBuilder`) with no +`ConfigureKestrel` call in code, so endpoints come entirely from the standard +`Kestrel` configuration section. On the deployed hosts these values are supplied +as NSSM environment variables (`Kestrel__Endpoints__...`), not from +`appsettings.json`. + +Two named endpoints are bound: + +| Endpoint name | Purpose | Protocol requirement | +|---|---|---| +| `Http` | Public gRPC API (sessions, invoke, events, Galaxy browse) | HTTP/2 | +| `Dashboard` | Blazor dashboard and SignalR hubs | HTTP/1.1 (HTTP/2 optional) | + +Both endpoints share one routing pipeline; the names only select which TCP port +serves which traffic. The gRPC endpoint must negotiate **HTTP/2**, which drives +the protocol settings below. + +### Plaintext (current deployments) + +Both running hosts (`10.100.0.48` and `wonder-app-vd03`) serve the gRPC port in +**cleartext HTTP/2 (`h2c`)**. Because cleartext HTTP/2 has no ALPN to negotiate +the protocol, the gRPC endpoint must be pinned to `Http2` with prior knowledge: + +```text +Kestrel__Endpoints__Http__Url=http://0.0.0.0:5120 +Kestrel__Endpoints__Http__Protocols=Http2 +Kestrel__Endpoints__Dashboard__Url=http://0.0.0.0:5130 +``` + +In this mode all client↔gateway traffic — including the +`authorization: Bearer mxgw_...` API key and any `WriteSecured` / `AuthenticateUser` +payloads — crosses the network **unencrypted**. This is acceptable only on a +trusted/isolated network segment. Prefer TLS for anything else. + +### TLS + +To encrypt the gRPC channel, give the `Http` endpoint an `https://` URL and a +certificate. Over TLS, ALPN negotiates HTTP/2, so the explicit `Protocols=Http2` +pin is no longer required (the default `Http1AndHttp2` works for gRPC over TLS). + +`appsettings.json` form: + +```json +{ + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "https://0.0.0.0:5120", + "Certificate": { + "Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx", + "Password": "" + } + }, + "Dashboard": { + "Url": "https://0.0.0.0:5130", + "Certificate": { + "Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx", + "Password": "" + } + } + } + } +} +``` + +Equivalent NSSM environment-variable form (how config is delivered on the hosts — +see [server deploy mechanics in the project notes]): + +```text +Kestrel__Endpoints__Http__Url=https://0.0.0.0:5120 +Kestrel__Endpoints__Http__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx +Kestrel__Endpoints__Http__Certificate__Password= +Kestrel__Endpoints__Dashboard__Url=https://0.0.0.0:5130 +Kestrel__Endpoints__Dashboard__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx +Kestrel__Endpoints__Dashboard__Certificate__Password= +``` + +Certificate sourcing options (any standard ASP.NET Core form is accepted): + +| Form | Keys | +|---|---| +| PFX file | `Certificate:Path` (+ `Certificate:Password` if encrypted) | +| PEM pair | `Certificate:Path` (cert) + `Certificate:KeyPath` (private key) | +| Windows cert store | `Certificate:Subject`, `Certificate:Store` (e.g. `My`), `Certificate:Location` (`LocalMachine`), `Certificate:AllowInvalid` | + +The certificate's CN/SAN must cover the host name clients dial (or clients must +set a server-name override — see below). The dashboard endpoint can keep its own +certificate independent of the gRPC endpoint; pair this with +`MxGateway:Dashboard:RequireHttpsCookie` (`true`) for production HTTPS. + +### Automatic self-signed certificate + +`mxaccessgw` is an internal tool with no PKI to issue certificates, so requiring +an operator to supply one before TLS works pushed deployments toward plaintext. +To avoid that, the gateway fills in a self-signed certificate when an HTTPS +endpoint is configured without one. + +**Trigger.** At startup the gateway inspects `Kestrel:Endpoints:*`. If any +endpoint has an `https://` URL and no `Certificate` subsection of its own, and no +`Kestrel:Certificates:Default` is set, the gateway generates (or loads) a +persisted self-signed certificate and wires it in as the HTTPS *default* via +`ConfigureHttpsDefaults`. All-plaintext deployments are untouched: when no HTTPS +endpoint is configured, no certificate or key material is generated or written. + +**Generated certificate.** ECDSA P-256, `serverAuth` EKU, validity ≈ +`ValidityYears` (default 10 years, with one day of clock-skew slack before +`notBefore`). SANs cover `localhost`, the machine name (and its FQDN when +resolvable), each entry in `AdditionalDnsNames`, and the loopback addresses +`127.0.0.1` and `::1`. + +**`MxGateway:Tls:*` options.** All optional; the zero-config path needs none of +them. + +| Option | Default | Purpose | +|---|---|---| +| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted | +| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) | +| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) | +| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing | + +`ValidityYears` is validated by `GatewayOptionsValidator` (range 1–100); the +"HTTPS endpoint configured but no certificate available" fail-fast lives in the +bootstrap/provider, because the validator only sees the `MxGateway` section, not +`Kestrel:Endpoints`. + +**Persistence.** The PFX is written with an **empty** export password — a random +in-memory password could not be reused across restarts, which the +persist-and-reuse model requires. The private key is instead protected at rest by +filesystem permissions: a restrictive ACL on Windows (SYSTEM + Administrators, +inherited ACEs stripped) on the `certs` directory and file, and mode `0600` on +non-Windows. The write is atomic (hardened temp file, then move). The persisted +certificate is reused across restarts (stable thumbprint, so CA-pinning clients +keep working) and regenerated only when it is missing, expired (and +`RegenerateIfExpired` is `true`), or unreadable/corrupt. If the directory is not +writable or the ACL cannot be applied, the gateway fails fast with a diagnostic +naming the path rather than falling back to an in-memory certificate. + +**Logging.** On generate or load, the gateway logs the certificate thumbprint, +SAN list, and `notAfter` at Information. The PFX bytes, export password, and +private key are never logged. + +**Operator override.** The generated certificate is only the HTTPS *default*. To +use a real certificate, configure one explicitly — either per endpoint via +`Kestrel:Endpoints::Certificate` (`Path`/`Subject`/`Thumbprint`, etc., as +in the table above) or globally via `Kestrel:Certificates:Default`. An +explicitly-configured certificate takes precedence, and the gateway then writes +no self-signed material. + +### Client side + +Each official client opts into TLS explicitly. For the .NET client +(`MxGatewayClientOptions`): + +| Option | Effect | +|---|---| +| `UseTls` (default `false`) | Enables TLS. Requires an `https://` endpoint; an `https://` endpoint without `UseTls` fails validation, and vice versa. | +| `CaCertificatePath` | Pins a custom root (self-signed / private CA) using `CustomRootTrust` chain validation instead of the OS trust store; the .NET client also enforces the certificate hostname/SAN match on this path. | +| `RequireCertificateValidation` (default `false`) | Forces OS/system-trust verification on a TLS connection with no pinned CA. Leave `false` for the lenient default. | +| `ServerNameOverride` | SNI / certificate host name override when the dialed host differs from the certificate CN/SAN. | + +To pair with the auto-generated self-signed certificate above, the clients are +**lenient by default**: a TLS connection with no pinned CA accepts whatever +certificate the gateway presents. Pin `CaCertificatePath` to verify, or set +`RequireCertificateValidation` to force system-trust verification without +pinning. The other language clients expose the equivalent options; the exact +behavior differs per stack — Python uses trust-on-first-use and Rust is pin-only. +See each client README for the as-built behavior. + +### Gateway↔worker IPC + +Transport security here applies only to the public gRPC channel. The +gateway↔worker link is a per-session **named pipe** +(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not +TLS-encrypted and does not need to be: it never leaves the local Windows host and +is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md). + ## Related Documentation - [Gateway Process Detailed Design](./GatewayProcessDesign.md) diff --git a/docs/Grpc.md b/docs/Grpc.md index f601c38..0043127 100644 --- a/docs/Grpc.md +++ b/docs/Grpc.md @@ -243,9 +243,27 @@ services.AddGrpc(options => options.Interceptors.Add Date: Mon, 1 Jun 2026 07:45:15 -0400 Subject: [PATCH 22/27] fix(gateway): delete temp cert file on persist failure Wrap the WriteAllBytes/Move/HardenPermissions sequence in a try/catch so that any failure best-effort deletes the hardened .tmp file (which may already hold PFX/private-key bytes) before rethrowing. Add a test that induces a persist failure by pointing SelfSignedCertPath inside a regular file and asserts no .tmp is left on disk. --- .../Tls/SelfSignedCertificateProvider.cs | 16 ++++++++-- .../Tls/SelfSignedCertificateProviderTests.cs | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index bbd33ff..9fa8640 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -146,9 +146,19 @@ public sealed class SelfSignedCertificateProvider HardenPermissions(temp); // Writing into an existing file truncates content but preserves its ACL/mode. - File.WriteAllBytes(temp, pfx); - File.Move(temp, path, overwrite: true); - HardenPermissions(path); + // If the write or move fails the hardened temp file (which may contain private-key + // material) must not be left on disk; delete it best-effort before rethrowing. + try + { + File.WriteAllBytes(temp, pfx); + File.Move(temp, path, overwrite: true); + HardenPermissions(path); + } + catch (Exception) + { + try { File.Delete(temp); } catch { /* best effort */ } + throw; + } X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags()); Log("Generated", loaded); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs index e75375e..7b8dfc8 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs @@ -116,6 +116,37 @@ public sealed class SelfSignedCertificateProviderTests () => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate()); } + /// + /// Verifies that GenerateAndPersist cleans up the hardened .tmp file when persist fails. + /// The failure is induced by setting SelfSignedCertPath to a path whose parent directory + /// is an existing regular file, causing Directory.CreateDirectory (or the subsequent write) + /// to throw an IOException/UnauthorizedAccessException. + /// + [Fact] + public void LoadOrCreate_DeletesTempFile_WhenPersistFails() + { + string outerDir = Directory.CreateTempSubdirectory().FullName; + try + { + // Create a regular file at what would be the parent directory of the cert path. + // Any attempt to create that "directory" or write files into it must fail. + string fileActingAsDir = Path.Combine(outerDir, "notadir"); + File.WriteAllText(fileActingAsDir, "block"); + + // Point the cert path inside the regular file — Directory.CreateDirectory will + // throw because the parent path component is a file, not a directory. + string certPath = Path.Combine(fileActingAsDir, "gw.pfx"); + string expectedTemp = certPath + ".tmp"; + + TlsOptions options = new() { SelfSignedCertPath = certPath }; + Assert.ThrowsAny(() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate()); + + // The .tmp file must not be left behind. + Assert.False(File.Exists(expectedTemp), $"Leaked temp file: {expectedTemp}"); + } + finally { Directory.Delete(outerDir, recursive: true); } + } + private const string SubjectAltNameOid = "2.5.29.17"; private static string ReadSubjectAltNames(X509Certificate2 cert) From e5c704de6903f6f7936e296a888e8d2b737fed75 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:52:48 -0400 Subject: [PATCH 23/27] feat(gateway): add machine FQDN to self-signed cert SANs Best-effort resolve the host FQDN via Dns.GetHostEntry and add it as a DNS SAN when it differs (OrdinalIgnoreCase) from the short machine name and "localhost". SocketException / ArgumentException are caught and silently skipped so cert generation remains robust when DNS is absent. --- .../Tls/SelfSignedCertificateProvider.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs index 9fa8640..17b33cb 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; @@ -55,6 +56,21 @@ public sealed class SelfSignedCertificateProvider san.AddDnsName(machine); } + // Best-effort: add the machine FQDN when it differs from the short name and "localhost". + // GetHostEntry may fail if DNS is unavailable; skip silently in that case. + try + { + string fqdn = Dns.GetHostEntry(machine).HostName; + if (!string.IsNullOrWhiteSpace(fqdn) + && !fqdn.Equals("localhost", StringComparison.OrdinalIgnoreCase) + && !fqdn.Equals(machine, StringComparison.OrdinalIgnoreCase)) + { + san.AddDnsName(fqdn); + } + } + catch (SocketException) { /* DNS not resolvable — FQDN SAN is optional */ } + catch (ArgumentException) { /* invalid host name — skip */ } + foreach (string extra in _options.AdditionalDnsNames) { if (!string.IsNullOrWhiteSpace(extra)) From 9bdb8997749f9619657381dee0a85eaf6f05f296 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:57:22 -0400 Subject: [PATCH 24/27] fix(clients): inline Go gosec directive and strip IPv6 brackets in Python authority split --- clients/go/mxgateway/client.go | 8 +++----- clients/python/src/zb_mom_ww_mxgateway/options.py | 14 +++++++++++++- clients/python/tests/test_tls.py | 11 +++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/clients/go/mxgateway/client.go b/clients/go/mxgateway/client.go index 9e3fab7..9bf2370 100644 --- a/clients/go/mxgateway/client.go +++ b/clients/go/mxgateway/client.go @@ -234,11 +234,9 @@ func tlsConfigForOptions(opts Options) *tls.Config { return nil } return &tls.Config{ - MinVersion: tls.VersionTLS12, - ServerName: opts.ServerNameOverride, - //nolint:gosec // internal tool; self-signed cert is the expected gateway default; - // opt-in to strict verification via RequireCertificateValidation. - InsecureSkipVerify: !opts.RequireCertificateValidation, + MinVersion: tls.VersionTLS12, + ServerName: opts.ServerNameOverride, + InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation } } diff --git a/clients/python/src/zb_mom_ww_mxgateway/options.py b/clients/python/src/zb_mom_ww_mxgateway/options.py index b4e1645..29caf29 100644 --- a/clients/python/src/zb_mom_ww_mxgateway/options.py +++ b/clients/python/src/zb_mom_ww_mxgateway/options.py @@ -74,8 +74,20 @@ class BrowseChildrenOptions: def _split_authority(endpoint: str) -> tuple[str, int]: - """Split a gRPC target (optionally scheme-prefixed) into (host, port).""" + """Split a gRPC target (optionally scheme-prefixed) into (host, port). + + Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``), + returning the host without brackets so it is safe to pass to + ``ssl.get_server_certificate``. + """ target = endpoint.split("://", 1)[-1] + if target.startswith("["): + # Bracketed IPv6: "[::1]:5120" or "[::1]" + bracket_end = target.find("]") + host = target[1:bracket_end] # strip surrounding brackets + remainder = target[bracket_end + 1 :] # ":5120" or "" + port_str = remainder.lstrip(":") + return (host, int(port_str) if port_str else 443) host, _, port = target.rpartition(":") return (host or "localhost", int(port) if port else 443) diff --git a/clients/python/tests/test_tls.py b/clients/python/tests/test_tls.py index 2547011..d7c1c4b 100644 --- a/clients/python/tests/test_tls.py +++ b/clients/python/tests/test_tls.py @@ -134,6 +134,17 @@ def test_split_authority_parses_host_and_port() -> None: assert _split_authority(":5120") == ("localhost", 5120) +def test_split_authority_strips_ipv6_brackets() -> None: + from zb_mom_ww_mxgateway.options import _split_authority + + # Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate + assert _split_authority("[::1]:5120") == ("::1", 5120) + # Bare bracketed IPv6 (no port) — default port 443 + assert _split_authority("[::1]") == ("::1", 443) + # Scheme-prefixed bracketed IPv6 + assert _split_authority("grpc://[::1]:5120") == ("::1", 5120) + + def test_tofu_connect_failure_raises_transport_error() -> None: """A failed cert pre-fetch surfaces the client's transport error type.""" options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}") From a912bffad5ac6c789d0339d0a4fe18e30200d56b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 13:29:39 -0400 Subject: [PATCH 25/27] build: reference ZB.MOM.WW.Health from the Gitea feed --- nuget.config | 19 +++++++++++++++++++ .../ZB.MOM.WW.MxGateway.Server.csproj | 1 + 2 files changed, 20 insertions(+) create mode 100644 nuget.config diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..5223539 --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index bbce9d8..c8c8527 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -6,6 +6,7 @@ + From 136614be942b55f7898b9663892e915acf398d0c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 13:33:54 -0400 Subject: [PATCH 26/27] feat: add AuthStoreHealthCheck readiness probe --- .../Diagnostics/AuthStoreHealthCheck.cs | 40 +++++++++++++++ .../Diagnostics/AuthStoreHealthCheckTests.cs | 49 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs new file mode 100644 index 0000000..59f1544 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs @@ -0,0 +1,40 @@ +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Server.Diagnostics; + +/// +/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway +/// authenticates every gRPC call against this store, so its reachability gates readiness. +/// +public sealed class AuthStoreHealthCheck : IHealthCheck +{ + private readonly AuthSqliteConnectionFactory _connectionFactory; + + public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) => + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using SqliteConnection connection = + await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT 1;"; + await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy("Auth store is reachable."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs new file mode 100644 index 0000000..3a9aa1f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Diagnostics; +using ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics; + +public sealed class AuthStoreHealthCheckTests +{ + private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath) + { + // GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both + // init-only, so populate them through object initializers. + var options = new GatewayOptions + { + Authentication = new AuthenticationOptions { SqlitePath = sqlitePath }, + }; + return new AuthSqliteConnectionFactory(Options.Create(options)); + } + + [Fact] + public async Task Healthy_WhenStoreReachable() + { + var path = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}.db"); + try + { + var check = new AuthStoreHealthCheck(FactoryFor(path)); + var result = await check.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + finally { if (File.Exists(path)) File.Delete(path); } + } + + [Fact] + public async Task Unhealthy_WhenPathUnusable() + { + // A regular file used as a parent directory forces the open to fail. + var bogus = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}"); + await File.WriteAllTextAsync(bogus, "x"); + try + { + var check = new AuthStoreHealthCheck(FactoryFor(Path.Combine(bogus, "store.db"))); + var result = await check.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + finally { if (File.Exists(bogus)) File.Delete(bogus); } + } +} From 62ba5e94876ae62576a34e7dc312b2a8a95b829b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 13:44:13 -0400 Subject: [PATCH 27/27] feat: map canonical ZB health tiers; replace bypassing /health/live --- .../GatewayApplication.cs | 15 +++++++-------- .../Gateway/GatewayApplicationTests.cs | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 7b12193..beba939 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Configuration; +using ZB.MOM.WW.Health; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -63,7 +64,11 @@ public static class GatewayApplication builder.Services.AddGatewayConfiguration(); builder.Services.AddSqliteAuthStore(); builder.Services.AddGatewayGrpcAuthorization(); - builder.Services.AddHealthChecks(); + builder.Services.AddHealthChecks() + .AddTypeActivatedCheck( + "auth-store", + failureStatus: null, + tags: new[] { ZbHealthTags.Ready }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -169,13 +174,7 @@ public static class GatewayApplication { endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); - endpoints.MapGet( - "/health/live", - () => Results.Ok(new GatewayHealthReply( - Status: "Healthy", - DefaultBackend: GatewayContractInfo.DefaultBackendName, - WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion))) - .WithName("LiveHealth"); + endpoints.MapZbHealth(); endpoints.MapGrpcService(); endpoints.MapGrpcService(); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 5f2c746..9fb3f05 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -11,19 +11,22 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway; public sealed class GatewayApplicationTests { - /// Verifies that Build maps the live health check endpoint. + /// Verifies that Build maps the canonical three health tiers. [Fact] - public async Task Build_MapsLiveHealthEndpoint() + public async Task Build_MapsCanonicalHealthEndpoints() { await using WebApplication app = GatewayApplication.Build([]); - RouteEndpoint endpoint = Assert.Single( - ((IEndpointRouteBuilder)app).DataSources - .SelectMany(dataSource => dataSource.Endpoints) - .OfType(), - candidate => candidate.RoutePattern.RawText == "/health/live"); + var paths = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(dataSource => dataSource.Endpoints) + .OfType() + .Select(e => e.RoutePattern.RawText) + .ToHashSet(); - Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); + Assert.Contains("/health/ready", paths); + Assert.Contains("/health/active", paths); + Assert.Contains("/healthz", paths); + Assert.DoesNotContain("/health/live", paths); } /// Verifies that Build registers the gateway metrics service.