ADR 0057: An unreadable security posture is refused, not defaulted¶
Status: Accepted Date: 2026-08-12 Issue: #1512 Related: #1490 (Modbus option keys), #1501 (EtherNet/IP option keys)
Context¶
An IOModule asks for its OPC UA channel's message security in one string:
options:
securityMode: SignAndEncrypt
The driver passed that string to gopcua, which resolves it through
ua.MessageSecurityModeFromString. That function's default branch returns
0, which the OPC UA enumeration calls Invalid. So SignAndEncypt — one
letter short, and a spelling no editor flags — was neither an error nor
SignAndEncrypt. It was the absence of a mode, and the driver went on to open
the channel.
securityPolicy had the same shape one layer over. Gopcua formats the value
into a policy URI with FormatSecurityPolicyURI, which prefixes anything it
does not recognise rather than rejecting it, so Basic256SHA256 became a
well-formed URI naming a policy that does not exist.
Whether the server then refuses the handshake is the server's business. What we could say about our own behaviour is narrower and worse: a typo in the field that decides whether the traffic on this channel is signed and encrypted produced no complaint from us, at authoring time or at connect time.
This is not, on its face, a security decision. It is the same class of bug #1490 and #1501 fixed on the other two drivers: a value that does not parse falls back to a default in silence. It gets its own record because of what the field decides, and because the remedy is a posture rather than a parse. Refusing to build the driver means an OPC UA IOModule that was reachable yesterday is unreachable today, on a plant that is running, over a spelling.
Decision¶
A security option whose value we cannot resolve refuses the driver at construction. It never falls back to a default, and it never reaches the library that would map it to something else.
Concretely, in pkg/driver/opcua:
securityModeis matched exactly againstNone,SignandSignAndEncrypt. Anything else is an error naming the value and the set.securityPolicyis resolved to a canonical URI and checked againstuapolicy.SupportedPolicies()— what this client can actually speak, read from the library's own table rather than copied into ours. A short name and a full URI are both accepted; a name under the right prefix that no policy implements is not.- An absent key stays absent. Unset is not
None: the client leaves the option off entirely so gopcua chooses, rather than being pinned to no security by our default.
Invalid is the fourth member of the OPC UA MessageSecurityMode
enumeration and is deliberately not in the accepted set. It is what the
protocol calls the absence of a mode, not a mode a channel can be opened in.
A configuration that spells it is asking for exactly the outcome this refusal
exists to prevent, so the ADR treats it as a misconfiguration rather than as
an option. None is how a channel asks for no message security, and it is a
posture an author chose.
The refusal is loud in the place that can act on it. On the io-probe path the
error lands in IOModuleStatus.Error; on the runtime path the driver is not
built and the unit runtime says which module and which key.
Alternatives Considered¶
Warn and continue. Log the unrecognised mode, connect anyway. This keeps the running plant running and is what most libraries do. It was rejected because the log line is the same signal the silent default already had: the connection comes up, the trend updates, and nothing about the running system tells anyone the traffic is not protected the way the manifest says it is. A warning that nobody reads is indistinguishable from the bug.
Fall back to the strictest mode. Treat an unresolvable securityMode as
SignAndEncrypt. This fails safe on the confidentiality axis and fails badly
on every other one: the handshake is then refused by a server that does not
offer that mode, and the author is debugging a server error whose cause is a
typo in their own file. Guessing a posture on the author's behalf is the
thing this ADR is about, and guessing upward is still guessing.
Validate at admission instead. A webhook on IOModule could reject the
manifest before it is stored, which is a better place to learn. It is not an
alternative to this change but a later addition: spec.options is a free-form
map[string]string with no per-protocol schema, the gateway passes it through
verbatim, and a webhook would have to carry a copy of every driver's accepted
set. Refusing at construction puts the rule in the one place that already
knows it. If a webhook lands later it consumes the same tables.
Leave it to the server. A server configured to require SignAndEncrypt refuses an Invalid channel, so the mistake surfaces eventually. This is true and it is not enough: it makes our correctness contingent on someone else's configuration, and the case it misses — a permissive server that accepts the channel — is precisely the case where the traffic ends up unprotected.
Consequences¶
- An OPC UA IOModule with a mistyped
securityMode,securityPolicy,connectTimeoutorrequestTimeout, or with an option key this driver does not read, stops building its driver. On an existing deployment that is a behaviour change: a module that connected yesterday on a typo does not connect today. That is the intent — a channel whose security is not the security that was asked for should not carry process data — and it is why this landed separately from the other two drivers rather than as a rider. - The accepted policy list is derived from gopcua's tables at run time, so a
library upgrade that adds or drops a policy moves the accepted set and the
refusal message together. A test asserts the derived list is non-empty and
that every name it advertises is one
parseOptionsaccepts, so the two tables drifting apart cannot leave the refusal naming nothing. - The gateway's IOModule form offers exactly the nine keys this driver reads, so the form cannot produce a refusal. Its freeform "Additional Options" table still can: a key typed there is submitted unvalidated and now fails at the driver rather than being dropped. That is the same trade #1490 made for Modbus, and client-side validation of the freeform table is unfinished work for both.
- The same refusal now covers the simulation driver's
tickRateandseed.seedis not a security option, but it is the option that makes a run reproducible, so a value that silently does not seed destroys the evidence for whatever the run was demonstrating and says nothing. An explicitseed: 0is refused for the same reason: zero is the sentinel for "unseeded", so it reads as a request for determinism and delivers its opposite.