Files
mxaccessgw/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md
T

1203 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, string?>
{
["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<GatewayOptions>()!;
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;
/// <summary>
/// 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.
/// </summary>
public sealed class TlsOptions
{
/// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
public string SelfSignedCertPath { get; init; } =
@"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
/// <summary>Lifetime in years of a freshly generated certificate.</summary>
public int ValidityYears { get; init; } = 10;
/// <summary>Extra DNS SANs to embed (e.g. a load-balancer name).</summary>
public IReadOnlyList<string> AdditionalDnsNames { get; init; } = [];
/// <summary>Regenerate the persisted certificate when it has expired.</summary>
public bool RegenerateIfExpired { get; init; } = true;
}
```
Add to `GatewayOptions.cs` (after the `Alarms` property):
```csharp
/// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
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 711
**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<string> 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 711 (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<SelfSignedCertificateProvider>.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<X509EnhancedKeyUsageExtension>().Single();
Assert.Contains(eku.EnhancedKeyUsages.Cast<System.Security.Cryptography.Oid>(),
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 `<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />` (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;
/// <summary>
/// Generates and persists a long-lived self-signed certificate used as the
/// Kestrel HTTPS default when no operator certificate is configured.
/// </summary>
public sealed class SelfSignedCertificateProvider
{
private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1";
private readonly TlsOptions _options;
private readonly ILogger<SelfSignedCertificateProvider> _logger;
private readonly TimeProvider _timeProvider;
public SelfSignedCertificateProvider(
TlsOptions options,
ILogger<SelfSignedCertificateProvider> logger,
TimeProvider timeProvider)
{
_options = options;
_logger = logger;
_timeProvider = timeProvider;
}
/// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
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 711 (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<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
}
finally { Directory.Delete(dir, recursive: true); }
}
```
**Step 2: Run, expect FAIL.**
**Step 3: Implement** — add to `SelfSignedCertificateProvider`:
```csharp
/// <summary>Loads the persisted certificate, regenerating when missing,
/// expired (and allowed), or unreadable.</summary>
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 711
**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;
/// <summary>
/// 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).
/// </summary>
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 711
**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<Configuration.TlsOptions>()
?? new Configuration.TlsOptions();
using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole());
Security.Tls.SelfSignedCertificateProvider provider = new(
tlsOptions,
loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
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 16, 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
/// <summary>
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
/// 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.
/// </summary>
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 17, 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 18, 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 19, 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 110
**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<rustls::crypto::CryptoProvider>);
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<rustls::client::danger::ServerCertVerified, rustls::Error> {
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<rustls::SignatureScheme> {
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`.