Files
lmxopcua/docs/security.md
Joseph Doherty 4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00

20 KiB

Transport Security

Overview

The LmxOpcUa server supports configurable transport security profiles that control how data is protected on the wire between OPC UA clients and the server.

There are two distinct layers of security in OPC UA:

  • Transport security -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the Security configuration section controls.
  • UserName token encryption -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. This means UserName authentication works on None endpoints too — the credentials themselves are always encrypted. However, a secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.

Supported Security Profiles

The server supports seven transport security profiles:

Profile Name Security Policy Message Security Mode Description
None None None No signing or encryption. Suitable for development and isolated networks only.
Basic256Sha256-Sign Basic256Sha256 Sign Messages are signed but not encrypted. Protects against tampering but data is visible on the wire.
Basic256Sha256-SignAndEncrypt Basic256Sha256 SignAndEncrypt Messages are both signed and encrypted. Full protection against tampering and eavesdropping.
Aes128_Sha256_RsaOaep-Sign Aes128_Sha256_RsaOaep Sign Modern profile with AES-128 encryption and SHA-256 signing.
Aes128_Sha256_RsaOaep-SignAndEncrypt Aes128_Sha256_RsaOaep SignAndEncrypt Modern profile with AES-128 encryption. Recommended for production.
Aes256_Sha256_RsaPss-Sign Aes256_Sha256_RsaPss Sign Strongest profile with AES-256 and RSA-PSS signatures.
Aes256_Sha256_RsaPss-SignAndEncrypt Aes256_Sha256_RsaPss SignAndEncrypt Strongest profile. Recommended for high-security deployments.

Multiple profiles can be enabled simultaneously. The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.

If no valid profiles are configured (or all names are unrecognized), the server falls back to None with a warning in the log.

Configuration

Transport security is configured in the Security section of appsettings.json:

{
  "Security": {
    "Profiles": ["None"],
    "AutoAcceptClientCertificates": true,
    "RejectSHA1Certificates": true,
    "MinimumCertificateKeySize": 2048,
    "PkiRootPath": null,
    "CertificateSubject": null
  }
}

Properties

Property Type Default Description
Profiles string[] ["None"] List of security profile names to expose as server endpoints. Valid values: None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt, Aes128_Sha256_RsaOaep-Sign, Aes128_Sha256_RsaOaep-SignAndEncrypt, Aes256_Sha256_RsaPss-Sign, Aes256_Sha256_RsaPss-SignAndEncrypt. Profile names are case-insensitive. Duplicates are ignored.
AutoAcceptClientCertificates bool true When true, the server automatically trusts client certificates that are not already in the trusted store. Set to false in production for explicit trust management.
RejectSHA1Certificates bool true When true, client certificates signed with SHA-1 are rejected. SHA-1 is considered cryptographically weak.
MinimumCertificateKeySize int 2048 Minimum RSA key size (in bits) required for client certificates. Certificates with shorter keys are rejected.
PkiRootPath string? null (defaults to %LOCALAPPDATA%\OPC Foundation\pki) Override for the PKI root directory where certificates are stored. When null, uses the OPC Foundation default location.
CertificateSubject string? null (defaults to CN={ServerName}, O=ZB MOM, DC=localhost) Override for the server certificate subject name. When null, the subject is derived from the configured ServerName.

Example: Development (no security)

{
  "Security": {
    "Profiles": ["None"],
    "AutoAcceptClientCertificates": true
  }
}

Example: Production (encrypted only)

{
  "Security": {
    "Profiles": ["Basic256Sha256-SignAndEncrypt"],
    "AutoAcceptClientCertificates": false,
    "RejectSHA1Certificates": true,
    "MinimumCertificateKeySize": 2048
  }
}

Example: Mixed (sign and encrypt endpoints, no plaintext)

{
  "Security": {
    "Profiles": ["Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"],
    "AutoAcceptClientCertificates": false
  }
}

PKI Directory Layout

The server stores certificates in a directory-based PKI store. The default root is:

%LOCALAPPDATA%\OPC Foundation\pki\

This can be overridden with the PkiRootPath setting. The directory structure is:

pki/
  own/        Server's own application certificate and private key
  issuer/     CA certificates that issued trusted client certificates
  trusted/    Explicitly trusted client (peer) certificates
  rejected/   Certificates that were presented but not trusted

Certificate Trust Flow

When a client connects using a secure profile (Sign or SignAndEncrypt), the following trust evaluation occurs:

  1. The client presents its application certificate during the secure channel handshake.
  2. The server checks whether the certificate exists in the trusted/ store.
  3. If found, the connection proceeds (subject to key size and SHA-1 checks).
  4. If not found and AutoAcceptClientCertificates is true, the certificate is automatically copied to trusted/ and the connection proceeds.
  5. If not found and AutoAcceptClientCertificates is false, the certificate is copied to rejected/ and the connection is refused.
  6. Regardless of trust status, the certificate must meet the MinimumCertificateKeySize requirement and pass the SHA-1 check (if RejectSHA1Certificates is true).

On first startup with a secure profile, the server automatically generates a self-signed application certificate in the own/ directory if one does not already exist.

Production Hardening

The default settings prioritize ease of development. Before deploying to production, apply the following changes:

1. Disable automatic certificate acceptance

Set AutoAcceptClientCertificates to false so that only explicitly trusted client certificates are accepted:

{
  "Security": {
    "AutoAcceptClientCertificates": false
  }
}

After changing this setting, you must manually copy each client's application certificate (the .der file) into the trusted/ directory.

2. Remove the None profile

Remove None from the Profiles list to prevent unencrypted connections:

{
  "Security": {
    "Profiles": ["Aes256_Sha256_RsaPss-SignAndEncrypt"]
  }
}

3. Configure LDAP authentication

Enable LDAP authentication to validate credentials against the GLAuth server. LDAP group membership controls what each user can do (read, write, alarm acknowledgment). See Configuration Guide for the full LDAP property reference.

{
  "Authentication": {
    "AllowAnonymous": false,
    "AnonymousCanWrite": false,
    "Ldap": {
      "Enabled": true,
      "Host": "localhost",
      "Port": 3893,
      "BaseDN": "dc=lmxopcua,dc=local",
      "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
      "ServiceAccountPassword": "serviceaccount123"
    }
  }
}

While UserName tokens are always encrypted by the OPC UA stack (using the server certificate), enabling a secure transport profile adds protection against message-level tampering and data eavesdropping.

4. Review the rejected certificate store

Periodically inspect the rejected/ directory. Certificates that appear here were presented by clients but were not trusted. If you recognize a legitimate client certificate, move it to the trusted/ directory to grant access.

X.509 Certificate Authentication

The server supports X.509 certificate-based user authentication in addition to Anonymous and UserName tokens. When any non-None security profile is configured, the server advertises UserTokenType.Certificate in its endpoint descriptions.

Clients can authenticate by presenting an X.509 certificate. The server extracts the Common Name (CN) from the certificate subject and assigns the AuthenticatedUser and ReadOnly roles. The authentication is logged with the certificate's CN, subject, and thumbprint.

X.509 authentication is available automatically when transport security is enabled -- no additional configuration is required.

Audit Logging

The server generates audit log entries for security-relevant operations. All audit entries use the AUDIT: prefix and are written to the Serilog rolling file sink for compliance review.

Audited events:

  • Authentication success: Logs username, assigned roles, and session ID
  • Authentication failure: Logs username and session ID
  • X.509 authentication: Logs certificate CN, subject, and thumbprint
  • Certificate validation: Logs certificate subject, thumbprint, and expiry for all validation events (accepted or rejected)
  • Write access denial: Logged by the role-based access control system when a user lacks the required role

Example audit log entries:

AUDIT: Authentication SUCCESS for user admin with roles [ReadOnly, WriteOperate, AlarmAck] session abc123
AUDIT: Authentication FAILED for user baduser from session def456
X509 certificate authenticated: CN=ClientApp, Subject=CN=ClientApp,O=Acme, Thumbprint=AB12CD34

CLI Examples

The Client CLI supports the -S (or --security) flag to select the transport security mode when connecting. Valid values are none, sign, encrypt, and signandencrypt.

Connect with no security

dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none

Connect with signing

dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign

Connect with signing and encryption

dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt

Browse with encryption and authentication

dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt -U operator -P secure-password -r -d 3

Read a node with signing

dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -S sign -n "ns=2;s=TestMachine_001/Speed"

The CLI tool auto-generates its own client certificate on first use (stored under %LOCALAPPDATA%\OpcUaCli\pki\own\). When connecting to a server with AutoAcceptClientCertificates set to false, you must copy the CLI tool's certificate into the server's trusted/ directory before the connection will succeed.

Troubleshooting

Certificate trust failure

Symptom: The client receives a BadSecurityChecksFailed or BadCertificateUntrusted error when connecting.

Cause: The server does not trust the client's certificate (or vice versa), and AutoAcceptClientCertificates is false.

Resolution:

  1. Check the server's rejected/ directory for the client's certificate file.
  2. Copy the .der file from rejected/ to trusted/.
  3. Retry the connection.
  4. If the server's own certificate is not trusted by the client, copy the server's certificate from pki/own/certs/ to the client's trusted store.

Endpoint mismatch

Symptom: The client receives a BadSecurityModeRejected or BadSecurityPolicyRejected error, or reports "No endpoint found with security mode...".

Cause: The client is requesting a security mode that the server does not expose. For example, the client requests SignAndEncrypt but the server only has None configured.

Resolution:

  1. Verify the server's configured Profiles in appsettings.json.
  2. Ensure the profile matching the client's requested mode is listed (e.g., add Basic256Sha256-SignAndEncrypt for encrypted connections).
  3. Restart the server after changing the configuration.
  4. Use the CLI tool to verify available endpoints:
    dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
    
    The output displays the security mode and policy of the connected endpoint.

Server certificate not generated

Symptom: The server logs a warning about application certificate check failure on startup.

Cause: The pki/own/ directory may not be writable, or the certificate generation failed.

Resolution:

  1. Ensure the service account has write access to the PKI root directory.
  2. Check that the PkiRootPath (if overridden) points to a valid, writable location.
  3. Delete any corrupt certificate files in pki/own/ and restart the server to trigger regeneration.

SHA-1 certificate rejection

Symptom: A client with a valid certificate is rejected, and the server logs mention SHA-1.

Cause: The client's certificate was signed with SHA-1, and RejectSHA1Certificates is true (the default).

Resolution:

  • Regenerate the client certificate using SHA-256 or stronger (recommended).
  • Alternatively, set RejectSHA1Certificates to false in the server configuration (not recommended for production).

LDAP Authentication

The server supports LDAP-based user authentication via GLAuth (or any standard LDAP server). When enabled, OPC UA UserName token credentials are validated by LDAP bind. LDAP group membership is resolved once during authentication and mapped to custom OPC UA role NodeIds in the urn:zbmom:lmxopcua:roles namespace. These role NodeIds are stored on the session's RoleBasedIdentity.GrantedRoleIds and checked directly during write and alarm-ack operations.

Architecture

OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials)
                                                  → LDAP Search (resolve group membership)
                                                  → Map groups to OPC UA role NodeIds
                                                  → Store on RoleBasedIdentity.GrantedRoleIds
                                                  → Permission checks via GrantedRoleIds.Contains()

LDAP Groups and OPC UA Permissions

All authenticated LDAP users can browse and read nodes regardless of group membership. Groups grant additional permissions:

LDAP Group Permission
ReadOnly No additional permissions (read-only access)
WriteOperate Write FreeAccess and Operate attributes
WriteTune Write Tune attributes
WriteConfigure Write Configure attributes
AlarmAck Acknowledge alarms

Users can belong to multiple groups. The admin user in the default GLAuth configuration belongs to all groups.

Effective Permission Matrix

The effective permission for a write operation depends on two factors: the user's session role (from LDAP group membership or anonymous access) and the Galaxy attribute's security classification. The security classification controls the node's AccessLevel — attributes classified as SecuredWrite, VerifiedWrite, or ViewOnly are exposed as read-only nodes regardless of the user's role. For writable classifications, the required write role depends on the classification.

FreeAccess Operate SecuredWrite VerifiedWrite Tune Configure ViewOnly
Anonymous (AnonymousCanWrite=true) Write Write Read Read Write Write Read
Anonymous (AnonymousCanWrite=false) Read Read Read Read Read Read Read
ReadOnly Read Read Read Read Read Read Read
WriteOperate Write Write Read Read Read Read Read
WriteTune Read Read Read Read Write Read Read
WriteConfigure Read Read Read Read Read Write Read
AlarmAck (only) Read Read Read Read Read Read Read
Admin (all groups) Write Write Read Read Write Write Read

All roles can browse and read all nodes. The "Read" entries above mean the node is either read-only by classification or the user lacks the required write role. "Write" means the write is permitted by both the node's classification and the user's role.

Alarm acknowledgment is an independent permission controlled by the AlarmAck role and is not affected by security classification.

GLAuth Setup

The project uses GLAuth v2.4.0 as the LDAP server, installed at C:\publish\glauth\. See C:\publish\glauth\auth.md for the complete user/group reference and service management commands.

Configuration

Enable LDAP in appsettings.json under Authentication.Ldap. See Configuration Guide for the full property reference.

Active Directory configuration

Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: Server, Port, UserNameAttribute, and ServiceAccountDn. The same GroupToRole mechanism works — map your AD security groups to OPC UA roles.

{
  "OpcUaServer": {
    "Ldap": {
      "Enabled": true,
      "Server": "dc01.corp.example.com",
      "Port": 636,
      "UseTls": true,
      "AllowInsecureLdap": false,
      "SearchBase": "DC=corp,DC=example,DC=com",
      "ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
      "ServiceAccountPassword": "<from your secret store>",
      "DisplayNameAttribute": "displayName",
      "GroupAttribute": "memberOf",
      "UserNameAttribute": "sAMAccountName",
      "GroupToRole": {
        "OPCUA-Operators": "WriteOperate",
        "OPCUA-Engineers": "WriteConfigure",
        "OPCUA-AlarmAck": "AlarmAck",
        "OPCUA-Tuners": "WriteTune"
      }
    }
  }
}

Notes:

  • UserNameAttribute: "sAMAccountName" is the critical AD override — the default uid is not populated on AD user entries, so the user-DN lookup returns no results without it. Use userPrincipalName instead if operators log in with user@corp.example.com form.
  • Port: 636 + UseTls: true is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set AllowInsecureLdap: false to refuse fallback.
  • ServiceAccountDn should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
  • memberOf values come back as full DNs like CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com. The authenticator strips the leading CN= RDN value so operators configure GroupToRole with readable group common-names.
  • Nested group membership is not expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups expansion is an authenticator enhancement, not a config change.

Security Considerations

  • LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use Basic256Sha256-SignAndEncrypt for production deployments.
  • The GLAuth LDAP server itself listens on plain LDAP (port 3893). Enable LDAPS in glauth.cfg for environments where LDAP traffic crosses network boundaries.
  • The service account password is stored in appsettings.json. Protect this file with appropriate filesystem permissions.