Files
scadaproj/tools/LdapProbe/CertificateInspector.cs
T
Joseph Doherty 4e01cbc2c3 feat(tools): ldapprobe -- NAM AD auth + group discovery probe
Diagnostic for pinning down the LDAP settings the four ZB.MOM.WW apps need
against the Zimmer NAM domain, and for answering which AD groups can be mapped
to app roles. Found the referral defect fixed in 0.2.1.

Two independent halves in one run:

Part A drives the REAL ZB.MOM.WW.Auth.LdapAuthService -- same bind-then-search-
then-bind, same options validator, same failure enum -- so its verdict IS what
the apps produce rather than an approximation of it.

Part B asks the directory what the library cannot, which is where the group
answer comes from: full group DNs, nested groups via
LDAP_MATCHING_RULE_IN_CHAIN, the primary group (AD omits it from memberOf, so
it can never be a role group), security vs distribution type, decoded
userAccountControl, referral counts, and the TLS chain with key sizes.
--list-groups '<pat>' enumerates every matching group in the directory, which
a membership dump structurally cannot do -- it only ever shows one account's
memberships.

Passwords are prompted for and never echoed; they cannot be passed as
arguments, which would put them in shell history. --self-bind lets the user
under test stand in for the service account, so group discovery does not
require the svc_igntnapp secret.

Not packable, outside the Auth solution and its central package management,
referencing the Auth projects directly so it tracks current source.
2026-08-12 21:27:14 -04:00

125 lines
4.8 KiB
C#

using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.Tools.LdapProbe;
/// <summary>
/// Wraps the TLS server-certificate validation callback so the probe can *report* the
/// presented chain rather than silently accept or reject it.
/// </summary>
/// <remarks>
/// This exists because <c>ldap_setup.md</c> records two distinct TLS hazards that both
/// present as a generic connect failure: the DC certs chain to the internal
/// <c>zimmer-rootca</c> (absent from a dev machine's trust store), and they carry
/// <b>RSA 1024-bit</b> keys, which OpenSSL at its default security level rejects outright.
/// Printing the subject, key size and the exact <see cref="SslPolicyErrors"/> turns
/// "connection refused" into a diagnosis.
/// </remarks>
public sealed class RemoteCertificateValidationCallbackHolder
{
private readonly bool _accept;
private readonly bool _report;
private int _reported;
public RemoteCertificateValidationCallbackHolder(bool acceptAnything, bool report)
{
_accept = acceptAnything;
_report = report;
}
/// <summary>The last chain observed, for the summary at the end of a run.</summary>
public string? Summary { get; private set; }
public RemoteCertificateValidationCallback Callback => Validate;
private bool Validate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
// Report once per process: a failover walk opens several connections and the
// chain is the same each time.
if (_report && Interlocked.Exchange(ref _reported, 1) == 0)
Describe(certificate, chain, errors);
return _accept || errors == SslPolicyErrors.None;
}
private void Describe(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
{
var lines = new List<string>();
if (certificate is null)
{
lines.Add("server presented NO certificate");
}
else
{
using var cert = new X509Certificate2(certificate);
lines.Add($"subject {cert.Subject}");
lines.Add($"issuer {cert.Issuer}");
lines.Add($"valid {cert.NotBefore:yyyy-MM-dd} -> {cert.NotAfter:yyyy-MM-dd}"
+ (cert.NotAfter < DateTime.Now ? " *** EXPIRED ***" : ""));
lines.Add($"sig alg {cert.SignatureAlgorithm.FriendlyName}");
var (algorithm, size) = KeyInfo(cert);
var weak = size is > 0 and < 2048;
lines.Add($"key {algorithm} {size}-bit" + (weak
? " *** WEAK — OpenSSL SECLEVEL>=2 (Debian .NET containers) rejects this ***"
: ""));
var san = cert.Extensions
.OfType<X509SubjectAlternativeNameExtension>()
.SelectMany(e => e.EnumerateDnsNames())
.ToArray();
if (san.Length > 0)
lines.Add($"SAN {string.Join(", ", san)}");
}
lines.Add($"policy {(errors == SslPolicyErrors.None ? "None (chain validated cleanly)" : errors.ToString())}");
if (chain is not null)
{
foreach (var element in chain.ChainElements)
{
var status = element.ChainElementStatus.Length == 0
? "ok"
: string.Join("; ", element.ChainElementStatus.Select(s => s.Status.ToString()));
lines.Add($" chain {Shorten(element.Certificate.Subject)} [{status}]");
}
}
lines.Add(_accept
? "verdict ACCEPTED because --insecure was passed (diagnostic only — do not ship this)"
: errors == SslPolicyErrors.None
? "verdict ACCEPTED by the OS trust store"
: "verdict REJECTED — rerun with --insecure to see past this, or install zimmer-rootca");
Summary = string.Join(Environment.NewLine, lines.Select(l => " " + l));
Ui.Section("TLS server certificate");
Console.WriteLine(Summary);
Console.WriteLine();
}
private static (string Algorithm, int Size) KeyInfo(X509Certificate2 cert)
{
try
{
using var rsa = cert.GetRSAPublicKey();
if (rsa is not null) return ("RSA", rsa.KeySize);
using var ecdsa = cert.GetECDsaPublicKey();
if (ecdsa is not null) return ("ECDSA", ecdsa.KeySize);
}
catch (CryptographicException)
{
// An unreadable key is itself worth reporting rather than crashing the probe.
}
return (cert.PublicKey.Oid.FriendlyName ?? "unknown", 0);
}
private static string Shorten(string dn)
{
var cn = dn.Split(',').FirstOrDefault(p => p.TrimStart().StartsWith("CN=", StringComparison.OrdinalIgnoreCase));
return cn?.Trim() ?? dn;
}
}