5d4dd17d7f
Driver.Mqtt.Transport-002 (Low, correctness). Credentials were attached only when Username was non-empty, so a config carrying a Password and no Username connected anonymously with the secret silently discarded — the deployment reads as authenticated and is not, which is the direction an operator cannot see from the config. Warned rather than sent. MQTT 3.1.1 may not carry a password without a username, so the password-only CONNECT is not available on every protocol version this factory serves, and synthesizing one under V500 only would change the wire behaviour of deployments that connect happily today. The configured values stay authoritative; the drop just stops being silent. The secret is never formatted into the message. Tests: the warning fires and does NOT contain the password, plus the falsifying control that an ordinary anonymous config (neither credential set) stays silent — without it the fix could warn on every connect and still pass.
303 lines
14 KiB
C#
303 lines
14 KiB
C#
using System.Net.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Microsoft.Extensions.Logging;
|
|
using MQTTnet;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
|
|
|
/// <summary>
|
|
/// Assembles the MQTTnet client options for every broker connection this repo opens — the runtime
|
|
/// driver's, the browse session's and the Test-connect probe's alike.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why this is its own type in its own assembly (Gitea #512).</b> It owns the security posture of
|
|
/// an MQTT connection: whether TLS is on, whether an untrusted certificate is tolerated, and — when a
|
|
/// PEM CA is pinned — the custom chain validator that replaces the OS trust anchor. Both the runtime
|
|
/// driver and the browse session must connect with <em>identical</em> semantics, and the failure mode
|
|
/// of a second copy is not a crash but a silent divergence: browse quietly not honouring a pinned CA
|
|
/// while the driver does. Previously this lived on <c>MqttConnection</c> and <c>.Browser</c> took a
|
|
/// documented layering exception to reach it; the exception is gone, and this is the one copy.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Pure.</b> No I/O, no network, nothing read from disk — so it is safe to call on a
|
|
/// config-validation path. A configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read
|
|
/// lazily by the returned validation callback at handshake time, not here.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class MqttClientOptionsFactory
|
|
{
|
|
/// <summary>
|
|
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
|
|
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
|
|
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
|
|
/// returned certificate-validation callback at handshake time, not here.
|
|
/// </summary>
|
|
/// <param name="options">Broker connection settings.</param>
|
|
/// <param name="clientIdSuffix">
|
|
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
|
|
/// the configured identity without colliding with the driver's own client id. When both are
|
|
/// empty MQTTnet generates a random client id.
|
|
/// </param>
|
|
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
|
|
/// <param name="lastWill">
|
|
/// Optional MQTT Last Will registered in the CONNECT packet (Gitea #511 — the Sparkplug
|
|
/// primary-host death certificate). See <see cref="MqttLastWill"/> for why this can only be
|
|
/// supplied here.
|
|
/// </param>
|
|
/// <param name="cleanSessionOverride">
|
|
/// Forces clean-session on or off, ignoring <see cref="MqttDriverOptions.CleanSession"/>. Used
|
|
/// by the primary-host connection, which must connect clean so the broker cannot resume a
|
|
/// previous session and discard the Will — see <see cref="MqttLastWill"/>.
|
|
/// </param>
|
|
public static MqttClientOptions BuildClientOptions(
|
|
MqttDriverOptions options,
|
|
string? clientIdSuffix,
|
|
ILogger? logger = null,
|
|
MqttLastWill? lastWill = null,
|
|
bool? cleanSessionOverride = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
var builder = new MqttClientOptionsBuilder()
|
|
.WithTcpServer(options.Host, options.Port)
|
|
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
|
|
.WithCleanSession(cleanSessionOverride ?? options.CleanSession)
|
|
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
|
|
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
|
|
|
|
if (lastWill is { } will)
|
|
{
|
|
builder = builder
|
|
.WithWillTopic(will.Topic)
|
|
.WithWillPayload(will.Payload)
|
|
.WithWillQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)will.Qos)
|
|
.WithWillRetain(will.Retain);
|
|
|
|
// MQTT 5.0 only. Left at its default the broker MAY hold the Will back for up to the
|
|
// session-expiry interval after an ungraceful disconnect, which for a host death
|
|
// certificate is precisely the wrong behaviour: the whole point is that edge nodes learn
|
|
// promptly. Zero means "publish it the moment the network connection is lost".
|
|
// The setter is harmless on 3.1.1 — the property simply has no wire representation there.
|
|
if (options.ProtocolVersion == MqttProtocolVersion.V500)
|
|
{
|
|
builder = builder.WithWillDelayInterval(0);
|
|
}
|
|
}
|
|
|
|
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
|
|
if (!string.IsNullOrWhiteSpace(clientId))
|
|
{
|
|
builder = builder.WithClientId(clientId);
|
|
}
|
|
|
|
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
|
|
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
|
|
if (!string.IsNullOrEmpty(options.Username))
|
|
{
|
|
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
|
|
}
|
|
else if (!string.IsNullOrEmpty(options.Password))
|
|
{
|
|
// A password with no username is dropped, and the drop is the direction an operator cannot
|
|
// see: the config reads as authenticated and the CONNECT goes out anonymous. MQTT 3.1.1 may
|
|
// not carry a password without a username, so synthesizing one is not available on every
|
|
// protocol version this factory serves — and inventing a password-only CONNECT under 5.0
|
|
// only would change the wire behaviour of deployments that connect happily today. Say so
|
|
// once, here, and leave the configured values authoritative. Never format the secret.
|
|
logger?.LogWarning(
|
|
"MQTT: a password is configured with no username, so it is NOT being sent — this connection "
|
|
+ "will be made anonymously. Set the username, or clear the password to make that intentional.");
|
|
}
|
|
|
|
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
|
|
|
|
return builder.Build();
|
|
}
|
|
|
|
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
|
|
{
|
|
if (!options.UseTls)
|
|
{
|
|
tls.UseTls(false);
|
|
return;
|
|
}
|
|
|
|
tls.UseTls(true)
|
|
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
|
|
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
|
|
.WithTargetHost(options.Host);
|
|
|
|
if (options.AllowUntrustedServerCertificate)
|
|
{
|
|
// A configured CA pin is DEAD in this branch — every certificate is accepted, pinned chain
|
|
// or not. Silence here reads to an operator as "the pin is in force", which is the one
|
|
// belief this combination must never leave them holding: the pin is what they set to make a
|
|
// MITM fail closed, and the escape hatch is what makes it fail open. Warn, do not
|
|
// second-guess — the explicit flag stays authoritative, so a deployment that genuinely wants
|
|
// the hatch keeps working and merely says so in its log.
|
|
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
|
{
|
|
logger?.LogWarning(
|
|
"MQTT: allowUntrustedServerCertificate is on, so the pinned CA '{CaCertificatePath}' is NOT "
|
|
+ "being enforced — ANY broker certificate is accepted. Clear the flag to make the pin "
|
|
+ "effective.",
|
|
options.CaCertificatePath);
|
|
}
|
|
|
|
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
|
|
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
|
|
tls.WithAllowUntrustedCertificates(true)
|
|
.WithIgnoreCertificateChainErrors(true)
|
|
.WithCertificateValidationHandler(static _ => true);
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
|
{
|
|
// No pin: MQTTnet's default handler validates against the OS trust store.
|
|
return;
|
|
}
|
|
|
|
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
|
|
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
|
|
/// (i.e. during the TLS handshake) so that this — and therefore
|
|
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
|
|
/// up to the pinned CA rejects the certificate: fail closed.
|
|
/// </summary>
|
|
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
|
|
string caCertificatePath,
|
|
ILogger? logger)
|
|
{
|
|
var trustedRoots = new Lazy<X509Certificate2Collection?>(
|
|
() => LoadPemCa(caCertificatePath, logger),
|
|
LazyThreadSafetyMode.ExecutionAndPublication);
|
|
|
|
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
|
|
}
|
|
|
|
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
|
|
{
|
|
try
|
|
{
|
|
var collection = new X509Certificate2Collection();
|
|
collection.ImportFromPemFile(caCertificatePath);
|
|
if (collection.Count != 0)
|
|
{
|
|
return collection;
|
|
}
|
|
|
|
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool ValidateAgainstPinnedCa(
|
|
MqttClientCertificateValidationEventArgs args,
|
|
X509Certificate2Collection? trustedRoots,
|
|
string caCertificatePath,
|
|
ILogger? logger)
|
|
{
|
|
if (trustedRoots is null || trustedRoots.Count == 0)
|
|
{
|
|
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
|
|
return false;
|
|
}
|
|
|
|
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
|
|
// mismatch, an absent certificate — remains fatal.
|
|
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
|
|
{
|
|
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
|
|
return false;
|
|
}
|
|
|
|
if (args.Certificate is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var presented = args.Certificate as X509Certificate2;
|
|
X509Certificate2? owned = null;
|
|
if (presented is null)
|
|
{
|
|
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
|
presented = owned;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var chain = new X509Chain();
|
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
|
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
|
|
|
|
// Revocation is deliberately not checked: the pin exists precisely for self-issued
|
|
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
|
|
// would fail every such chain. The pin itself is the revocation story — remove the CA.
|
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
|
|
|
// Seed the intermediates the broker presented during the handshake. A leaf signed by
|
|
// an intermediate that chains up to the pinned root is a common broker topology, and
|
|
// that intermediate arrives on the wire rather than being installed locally — without
|
|
// it in the candidate pool a legitimate trust path simply cannot be built.
|
|
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
|
|
// self-signed cert smuggled in here cannot become an anchor.
|
|
// Both sources are read: platforms differ in whether the peer-supplied intermediates
|
|
// land in the incoming chain's elements, its ExtraStore, or both.
|
|
if (args.Chain is not null)
|
|
{
|
|
foreach (var element in args.Chain.ChainElements)
|
|
{
|
|
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
|
|
}
|
|
|
|
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
|
|
}
|
|
|
|
if (chain.Build(presented))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
logger?.LogError(
|
|
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
|
|
caCertificatePath,
|
|
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
|
|
return false;
|
|
}
|
|
catch (CryptographicException ex)
|
|
{
|
|
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
|
|
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
|
|
// it here and refuse: an unverifiable certificate is a rejected certificate.
|
|
logger?.LogError(
|
|
ex,
|
|
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
|
|
caCertificatePath);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
owned?.Dispose();
|
|
}
|
|
}
|
|
|
|
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
|
|
=> version switch
|
|
{
|
|
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
|
|
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
|
|
};
|
|
}
|