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" +}