diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
index 4fe77477..47e93a45 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
+using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
@@ -95,6 +96,42 @@ public sealed record MqttDriverOptions
/// ; null in Sparkplug B mode.
///
public MqttPlainOptions? Plain { get; init; }
+
+ ///
+ /// Record-generated ToString() member printer, overridden so
+ /// never renders in plaintext — this DTO routinely lands in logs / exception messages /
+ /// debugger watches, and the plan's cross-cutting rule is explicit: never commit or log
+ /// creds. A set password prints as ***; unset (null/empty) renders
+ /// distinguishably so the redaction can't be mistaken for a real secret.
+ ///
+ private bool PrintMembers(StringBuilder builder)
+ {
+ var passwordDisplay = Password switch
+ {
+ null => "",
+ "" => "",
+ _ => "***",
+ };
+
+ builder.Append("Host = ").Append(Host);
+ builder.Append(", Port = ").Append(Port);
+ builder.Append(", ClientId = ").Append(ClientId);
+ builder.Append(", UseTls = ").Append(UseTls);
+ builder.Append(", AllowUntrustedServerCertificate = ").Append(AllowUntrustedServerCertificate);
+ builder.Append(", CaCertificatePath = ").Append(CaCertificatePath);
+ builder.Append(", Username = ").Append(Username);
+ builder.Append(", Password = ").Append(passwordDisplay);
+ builder.Append(", ProtocolVersion = ").Append(ProtocolVersion);
+ builder.Append(", CleanSession = ").Append(CleanSession);
+ builder.Append(", KeepAliveSeconds = ").Append(KeepAliveSeconds);
+ builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds);
+ builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds);
+ builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds);
+ builder.Append(", Mode = ").Append(Mode);
+ builder.Append(", Sparkplug = ").Append(Sparkplug);
+ builder.Append(", Plain = ").Append(Plain);
+ return true;
+ }
}
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
index 29b6317f..ce7e2c3c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
@@ -36,4 +36,38 @@ public sealed class MqttDriverOptionsTests
s.ShouldContain("\"Plain\"");
s.ShouldNotContain("\"mode\":0");
}
+
+ // Pins the plan's cross-cutting rule ("never ship anonymous/public-broker defaults") as an
+ // executable assertion — nothing else in this suite fails if UseTls / AllowUntrustedServerCertificate
+ // (or the §5.1 numeric defaults) get flipped by an unrelated edit.
+ [Fact]
+ public void Defaults_SecurityCriticalKnobs_MatchPlan()
+ {
+ var o = new MqttDriverOptions();
+ o.UseTls.ShouldBeTrue();
+ o.AllowUntrustedServerCertificate.ShouldBeFalse();
+ o.ConnectTimeoutSeconds.ShouldBe(15);
+ o.ReconnectMinBackoffSeconds.ShouldBe(1);
+ o.ReconnectMaxBackoffSeconds.ShouldBe(30);
+ }
+
+ // The production case: an operator-authored config blob that simply doesn't mention TLS must
+ // never silently deserialize into an insecure instance.
+ [Fact]
+ public void Deserialize_PartialJsonOmittingTlsKnobs_KeepsSecureDefaults()
+ {
+ const string json = """{ "host":"10.100.0.35","port":8883 }""";
+ var o = JsonSerializer.Deserialize(json, J)!;
+ o.UseTls.ShouldBeTrue();
+ o.AllowUntrustedServerCertificate.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ToString_DoesNotLeakPassword()
+ {
+ var o = new MqttDriverOptions { Password = "hunter2-supersecret" };
+ var s = o.ToString();
+ s.ShouldNotContain("hunter2-supersecret");
+ s.ShouldContain("***");
+ }
}