feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only when a session is created with checkDomain:true. The driver, the AdminUI browser and Client.Shared all called the 8-argument DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine over an IP in #813). When enabled the SDK (Session.OpenAsync -> ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost — the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector rewrote the advertised one — against the cert's DNS/IP SANs; secured sessions only, and the auto-accept handler accepts a mismatch. Policy: certificateValidation.validateServerHostName, tri-state. Unset = OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the pre-#816 behaviour, so nothing changes on upgrade; true = validate the configured host; false = explicit NAT/port-publish opt-out. Threaded into the driver, the AdminUI browser (same JSON) and the AdminUI form (a select that keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName and the CLI --validate-host-name. A driver refusal names the opt-out. #818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified: same thumbprint across --force-recreate), its PUBLIC cert is committed as Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run every secure browse on a throwaway PKI (internal browser seam): refusal with nothing trusted, positive with the cert trusted (unset/false host check), and a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch); OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT container's public cert. ServerHostNameValidationTests pin the driver against the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in v2-integration (440 -> 446), documented in scripts/ci/README.md.
This commit is contained in:
@@ -108,7 +108,14 @@ The rule, for `opc.tcp` (and any scheme, as long as advertised and configured sc
|
||||
a real rewrite keeps the advertised path's percent-escaping (`AbsoluteUri`, #812);
|
||||
- a **different scheme** (an `https` endpoint next to an `opc.tcp` one) is a different transport and is left alone;
|
||||
- an unusable advertised URL (null / blank / unparseable) falls back to the configured one in the driver and the
|
||||
browser; the Client CLI/UI instead reports it (it validates before calling the rewriter).
|
||||
browser; the Client CLI/UI instead reports it (it validates before calling the rewriter);
|
||||
- an **IPv6 zone index** on the configured host (`opc.tcp://[fe80::1%eth0]:4840`) is **kept, verbatim** (#819).
|
||||
.NET's `Uri` parses the zone but drops it from `Host`/`Authority`/`AbsoluteUri`/`ToString()` — only
|
||||
`DnsSafeHost`/`IdnHost` carry it — so the rewriter splices it back into the string. The SDK dials
|
||||
`new Uri(EndpointUrl).IdnHost` (`TcpByteTransport` → `DnsEndPoint`), which keeps it, so a link-local endpoint now
|
||||
routes; before #819 the session dialled a zone-less `fe80::` address. The zone text is not normalised: write the
|
||||
form your OS resolves (`%eth0` / `%4`). .NET does **not** decode RFC 6874's `%25eth0` (its `DnsSafeHost` is
|
||||
`fe80::1%25eth0`), so that form is passed through as written — GetEndpoints and the session see the same string.
|
||||
|
||||
**What the rewrite does NOT touch.** It only chooses the network address. The **security policy and mode** are
|
||||
still selected from the server's advertisement exactly as before — the driver and browser filter on the configured
|
||||
@@ -118,7 +125,51 @@ user-token policies** are the advertised ones. **Certificate validation is uncha
|
||||
validates the certificate of the server actually reached, against the same trust store (the driver's PKI, the
|
||||
browse picker's separate `adminui-browse-pki`, the CLI's own) with the same `AutoAcceptCertificates` semantics. The
|
||||
NAT tests below pin that a secure browse through the NAT is refused on an untrusted certificate and succeeds once it
|
||||
is trusted.
|
||||
is trusted. The optional **host-name** check (#816, next section) is the one place the rewrite matters to validation:
|
||||
it runs against the rewritten URL, i.e. the **configured** host.
|
||||
|
||||
### Server certificate host name (#816)
|
||||
|
||||
**Finding (SDK 2.0.1).** The SDK only checks that the server certificate names the host you connected to when the
|
||||
session is created with `checkDomain: true`. All three surfaces — the driver (`OpenSessionOnEndpointAsync`), the
|
||||
AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`DefaultSessionFactory`) — called the
|
||||
8-argument `DefaultSessionFactory.CreateAsync(config, endpoint, updateBeforeConnect, sessionName, …)`, which
|
||||
forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone.
|
||||
That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP.
|
||||
|
||||
Where the check lives when enabled: `Session.OpenAsync` → after `CertificateManager.ValidateAsync` accepts the chain,
|
||||
`if (checkDomain) CertificateValidationExtensions.ValidateDomains(validator, serverCert, session.ConfiguredEndpoint)`
|
||||
→ `CertificateValidationCore.ValidateDomains` → `CertificateValidationHelpers.FindDomain`, which compares
|
||||
`ConfiguredEndpoint.EndpointUrl.IdnHost` with the certificate's DNS / IP SANs (IP literals normalised; a `localhost`
|
||||
/ `127.0.0.1` / `::1` URL is compared as the machine's own host name + FQDN). A miss is `BadCertificateHostNameInvalid`
|
||||
("The domain '…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook
|
||||
first. Consequences:
|
||||
|
||||
- **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` /
|
||||
`EndpointSelector` rewrote, so the advertised host the server named itself never takes part.
|
||||
- **Secured sessions only** — the block runs only when the channel (or the user-token policy) needs the server
|
||||
certificate; a `None` session validates no certificate at all.
|
||||
- **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true`
|
||||
for everything but revocation). The Client CLI always auto-accepts.
|
||||
- Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no
|
||||
certificate or host-name check.
|
||||
|
||||
**Policy: `certificateValidation.validateServerHostName`** (driver JSON; AdminUI driver form "Server host-name check";
|
||||
the browser reads the same JSON):
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| absent / `null` (**default**) | `OpcUaCertificateValidationOptions.DefaultValidateServerHostName` = **off** — exactly the pre-#816 behaviour, so no existing deployment changes on upgrade. The AdminUI form keeps it absent ("Default (off)") rather than writing a concrete `false`. |
|
||||
| `true` | Validate: the certificate must list the configured endpoint host. The driver's connect failure then appends the remedy (fix the certificate, connect by a name it lists, or opt out). |
|
||||
| `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. |
|
||||
|
||||
When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate
|
||||
it signed, so only the host check ties the certificate to *this* server). With the server's own self-signed
|
||||
certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client
|
||||
CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by
|
||||
`ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and
|
||||
`NatPublishedEndpointBrowseTests` (browser vs. the NAT opc-plc): unset/`false` connect, `true` is refused naming the
|
||||
configured host.
|
||||
|
||||
**Running the NAT tests.** `NatPublishedEndpointBrowseTests`
|
||||
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests`, `Fixture=opc-plc-nat`) run against
|
||||
@@ -133,13 +184,20 @@ OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test \
|
||||
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests --filter Fixture=opc-plc-nat
|
||||
```
|
||||
|
||||
The secure case asserts a certificate **refusal** by default. To make it positive, trust the fixture's server
|
||||
certificate in the browse PKI and set `OPCUA_NAT_SERVER_CERT_TRUSTED=1` — the certificate is regenerated whenever
|
||||
the container is recreated, so fetch the current one:
|
||||
`ssh dohertj2@10.100.0.35 'docker exec otopcua-opc-plc-nat sh -c "cat /app/pki/own/certs/*.der"' > "<LocalAppData>/OtOpcUa/adminui-browse-pki/trusted/certs/opc-plc-nat.der"`
|
||||
(`~/Library/Application Support/...` on macOS, `~/.local/share/...` on Linux), and delete it again afterwards. CI
|
||||
(`v2-integration.yml`) starts its own throwaway NAT copy (`scripts/ci/integration-fixtures.sh`); `v2-e2e.yml` points at
|
||||
the shared one. Neither trusts the certificate, so CI runs the refusal form.
|
||||
Every secure browse there runs on a **throwaway browse PKI** (an internal PKI-root seam on the browser), never your
|
||||
`adminui-browse-pki`. The refusal case trusts nothing and always runs. The positive case (unset / `false` host check)
|
||||
and the host-name refusal (`true`) trust the fixture's **public** certificate (#818), from:
|
||||
|
||||
1. `OPCUA_NAT_SERVER_CERT` — a DER or PEM file; must be the certificate the server presents, or the test **fails**; else
|
||||
2. the committed `tests/.../Driver.OpcUaClient.IntegrationTests/Docker/nat/opc-plc-nat-server.der` — the shared-host
|
||||
stack's certificate, stable because its PKI lives on the `opc-plc-nat-pki` volume. If it is not the certificate
|
||||
the server presents (another NAT fixture, or the stack's certificate was rolled) these tests **skip**.
|
||||
|
||||
The private key never leaves the volume. The committed certificate expires **2027-11-10**; to roll it, see the comment
|
||||
at the top of `Docker/nat/docker-compose.yml`. CI (`v2-integration.yml`) starts its own throwaway NAT copy
|
||||
(`scripts/ci/integration-fixtures.sh`), copies that container's public certificate out with `docker exec` and exports
|
||||
`OPCUA_NAT_SERVER_CERT`, so it runs the positive cases too; `v2-e2e.yml` points at the shared stack and uses the
|
||||
committed certificate.
|
||||
|
||||
### Session lifetime
|
||||
|
||||
@@ -379,6 +437,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change
|
||||
|---|---|---|
|
||||
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
|
||||
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
|
||||
| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Secured sessions only; accepted anyway under `autoAcceptCertificates`. `false` = explicit NAT opt-out. See "Server certificate host name" above. |
|
||||
|
||||
### Subscription tuning (#273)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user