1203 lines
49 KiB
Markdown
1203 lines
49 KiB
Markdown
# 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 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<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 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<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 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<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 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;
|
||
|
||
/// <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 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<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 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
|
||
/// <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 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<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`.
|