Release Notes — Router TLS validation tightening + certificate pinning

Sprint: SPRINT_20260501_013_Router_tls_pinning Module: Router transport (src/Router/__Libraries/StellaOps.Router.Transport.Tls/) Audit finding closed: Pass-2 §C8 (TLS self-signed bypass too broad)

Summary

Closes audit finding C8 in microservice-audit-pass2-2026-04-29.md: the previous TlsTransportServer.ValidateClientCertificate accepted a self-signed certificate with the wrong hostname when AllowSelfSigned=true was configured — a self-signed cert presenting any name passed validation, collapsing the TLS identity guarantee entirely.

The validator has been rewritten with strict, ordered rules. The same logic applies to the client-side ValidateServerCertificate callback so microservices validating the gateway use the identical contract.

See Router architecture, TLS Validation Contract for the full rules and TLS Transport guide, “Self-signed + pinning (worked example)” for a compose-file example.

⚠️ Breaking change — operator action required

Any deployment using AllowSelfSigned=true without certificate pinning will now fail to start. This is intentional — the previous configuration was the audit-finding C8 collapse and silently accepted self-signed certs from any peer with any hostname.

Service startup throws:

System.InvalidOperationException: TlsTransportOptions: AllowSelfSigned=true
requires at least one of CertificatePinning.ThumbprintsSha256,
CertificatePinning.AllowedSubjects, or CertificatePinning.AllowedSans to be
populated. Audit finding C8 (docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md) blocked
the previous unconstrained self-signed bypass; configure a pin set or set
AllowSelfSigned=false. See SPRINT_20260501_013 for the worked example.

Recovery — add a pin set

Compute the SHA-256 thumbprint of each peer leaf certificate:

openssl x509 -in cert.pem -outform DER | openssl dgst -sha256 -hex \
  | awk '{print toupper($2)}'

Configure pinning (one of three modes; any-of matches):

Router:
  Transport:
    Type: tls
    Tls:
      AllowSelfSigned: true
      CertificatePinning:
        ThumbprintsSha256:
          - "F2A1B3C4D5E6F708091A2B3C4D5E6F708091A2B3C4D5E6F708091A2B3C4D5E6F"
        # OR (more permissive — pin a private CA subject for leaf rotation):
        # AllowedSubjects:
        #   - "CN=stellaops-internal-ca, O=stellaops"
        # OR (pin DNS SAN entries — exact match, no wildcard expansion):
        # AllowedSans:
        #   - "service-a.stellaops.local"

Recovery — disable self-signed acceptance (recommended for production)

Use a properly trusted CA chain and set AllowSelfSigned: false (the default). Pinning remains optional but is applied as defence-in-depth when configured.

Validation rules

The new validator (TlsCertificateValidator) applies these rules in order:

  1. Required peer cert missing → reject (certificate_required_but_missing).
  2. RemoteCertificateNotAvailable → reject.
  3. RemoteCertificateNameMismatch→ reject unconditionally, even with AllowSelfSigned=true and a matching pin (name_mismatch). This is the central C8 fix.
  4. RemoteCertificateChainErrors:
    • AllowSelfSigned=false → reject.
    • AllowSelfSigned=true → accept only if every chain status flag is UntrustedRoot or PartialChain AND the cert matches at least one entry in the configured pin set.
  5. No errors → accept.

Pin matching uses StringComparer.Ordinal on a normalised SHA-256 hex thumbprint (separators stripped, upper-case).

Telemetry

Each rejection increments router_tls_validate_rejected_total{reason, role} (meter StellaOps.Router.Transport.Tls) and emits a structured-log warning. role is client_certificate (server-side mTLS validation) or server_certificate (microservice validating gateway). Reason codes: certificate_required_but_missing, remote_certificate_not_available, name_mismatch, chain_error_and_self_signed_disallowed, chain_status_not_permitted, pin_set_empty, pin_mismatch, other_ssl_policy_error.

Files changed