opcua Connector Spec — sessions, security and certificates
| Field | Value |
|---|---|
| Status | Implementable |
| Protocol id | opcua |
| Crate | connector-opcua (feature opcua) · C module impl/c/connectors/opcua/ (option TDOT_OPCUA) |
| Builds on | Rust: async-opcua (MPL-2.0), with async-opcua-crypto vendored and patched (impl/rust/vendor/async-opcua-crypto/TEDGE-DOT-PATCH.md). C: open62541 1.5 (MPL-2.0) with mbedTLS 3.6 (Apache-2.0), both built from source and linked statically. Connector SDK |
| Implements | OT Connector Contract v0.4.0 |
| Test vectors | connectors/opcua/conformance/tools/genpki.py (PKI scenarios, generated at test time) |
This spec is normative for both implementations. Where it says “the connector”, the Rust and the C build behave identically; the tests named in §10 hold them to it.
1. Scope
Section titled “1. Scope”| Operation | Services | Contract |
|---|---|---|
| Read | Read (Value attribute) | polled read_points |
| Monitor | CreateSubscription + CreateMonitoredItems (one subscription per device) | subscribe |
| Write | Write (Value attribute) | write (and SDK write-batch) |
| Discovery | GetEndpoints (secured devices and non-anonymous identities) | connect |
Security: the None, Basic256Sha256, Aes128_Sha256_RsaOaep and Aes256_Sha256_RsaPss
policies (and, when explicitly allowed, the deprecated Basic128Rsa15 and Basic256), modes
none, sign, sign_and_encrypt; anonymous, username and X.509 user identities; an
application instance certificate; server certificates trusted by pinning or through a CA chain
with CRLs.
Out of scope: GDS (push/pull certificate management), ECC policies, reverse connect, cloud-side certificate operations, keys in an HSM/TPM.
2. Capability descriptor
Section titled “2. Capability descriptor”{ "protocol": "opcua", "modes": ["raw", "typed"], "datatypes": ["bool","int8","uint8","int16","uint16","int32","uint32","int64","uint64","float32","float64","string","bytes"], "point_kinds": ["variable"], "command_verbs": ["write", "write-batch", "call-method"], "features": ["polling", "subscribe"], "subscribe": true }bytes (ByteString values, §3.7) is advertised by both builds, and so is call-method
(method calls, §3.10). A configuration with method points adds their list, methods (contract
§7), each with its address (object_id, method_id, inputs). Security is configuration, not a capability: both builds support all of §3.
3. Configuration
Section titled “3. Configuration”Relative paths anywhere in this section (pki_dir, certificate, private_key,
password_file, user_certificate, user_private_key) resolve against the directory of the
configuration file — never against the process working directory.
Local-only settings (contract §3.4): a management command (set-config, define-device)
may not add or change pki_dir, certificate, private_key, create_certificate,
password_file, user_certificate, user_private_key, trust_any_server_certificate,
allow_plaintext_password or allow_deprecated_security; such a command fails with
<place>.<key> may only be set in the configuration file, not by a management command.
Removing one of them is refused too (removing a whole device is not). An inline password is
allowed.
3.1 connection
Section titled “3.1 connection”| Key | Default | Meaning |
|---|---|---|
application_name | "tedge-dot" | The client’s application name (and the CN/O of a generated certificate). |
application_uri | "urn:tedge-dot" | The client’s application URI. It must be a URI SAN of the application certificate. |
connect_timeout_s | 15 | Bound on discovery and session activation. |
request_timeout_s | connector.operation_timeout | Bound on a service call. |
security_policy | "None" | Default policy of every device (§3.3). |
security_mode | policy default | Default mode of every device (§3.3). |
pki_dir | /var/lib/tedge-dot/opcua/pki | The PKI directory (§4). |
certificate, private_key | — | The application certificate (DER or PEM) and key (PEM). Both or neither; when set they are used instead of the PKI directory’s own/ entries and never generated. |
create_certificate | true | Generate a certificate when none exists (§4.2). |
trust_any_server_certificate | false | Accept any server certificate (§5.4). Devices may override. |
allow_deprecated_security | false | Allow Basic128Rsa15 and Basic256. Devices may override. |
allow_plaintext_password | false | Allow a password to travel unencrypted (§6.2). Devices may override. |
queue_size | 16 | How many values a monitored item queues between two publishes (§3.8). Devices and points may override. |
A value of the wrong type is a configuration error naming [connection] and the key.
3.2 device.protocol_address
Section titled “3.2 device.protocol_address”| Key | Required | Meaning |
|---|---|---|
endpoint | yes | opc.tcp://host[:port][/path]. The connector always dials this host and port (§5.1). |
security_policy, security_mode | no | Override the [connection] defaults (§3.3). |
user | with a password | Username identity. |
password | password_file | no | At most one. A password_file is read at configure: its first line, without the line ending. A user without either has an empty password. |
user_certificate, user_private_key | together | X.509 user identity (certificate DER or PEM, key PEM). The key must match the certificate and must not be readable by group or others. |
trust_any_server_certificate, allow_deprecated_security, allow_plaintext_password | no | Per-device overrides of the [connection] switches. |
queue_size | no | The device’s monitored-item queue size (§3.8); overrides [connection]. |
namespaces | no | The namespace table the device’s own node ids were written against (§3.9). |
Exactly one identity: anonymous (no identity key), username, or X.509. Mixing them, a password
without user, both password and password_file, or only one of the two certificate keys is
a configuration error.
Secrets are never echoed: not in log output, link status, health, command results, describe
output or configuration errors. A password that is not a string is reported without its value.
3.3 Policy and mode
Section titled “3.3 Policy and mode”Policy names are the URI fragments (Basic256Sha256, Aes128_Sha256_RsaOaep,
Aes256_Sha256_RsaPss, …); the full http://opcfoundation.org/UA/SecurityPolicy#… URI is
accepted too. Modes: none, sign, sign_and_encrypt (signandencrypt is accepted as an
alias).
- The device’s
security_policywins over the connection’s. A device that sets its own policy but no mode gets that policy’s default mode — it does not inherit the connection’s mode. Otherwise the device’s mode wins over the connection’s. - A policy’s default mode is
noneforNoneandsign_and_encryptfor every other policy. Nonerequiresnone; every other policy requiressignorsign_and_encrypt.- Deprecated policies need
allow_deprecated_security = true.
Violations are configuration errors naming the device and the field: device 'plc': security_mode 'none' cannot be used with security_policy 'Basic256Sha256' ….
3.4 point.address
Section titled “3.4 point.address”{ node_id = "ns=2;s=Temperature" } or { namespace = 2, identifier = "Temperature" | 1001 }.
The namespace index is the server’s, unless the point has a namespace table (§3.9): then it is
the table’s, and translated to the server’s on every connect.
Two optional keys select one value out of a structured or array value (§3.7):
| Key | Meaning |
|---|---|
field | A dotted path into a structure: "Speed", "Motor.Current", "Items[1].Value". A segment may carry one zero-based element index [n]. |
index | One zero-based element of an array variable, selected on the server through IndexRange. May be combined with field when the elements are structures. |
A method point (access = "call_method") has its own address keys; see §3.10.
One more optional key applies to subscribed points:
| Key | Meaning |
|---|---|
queue_size | The point’s monitored-item queue size (§3.8); overrides the device’s. |
Points with either key are read-only (access = "write" or "read_write" is a configuration
error). field needs mode = "typed": a raw point reads the whole structure body.
3.5 Reporting policy (report)
Section titled “3.5 Reporting policy (report)”The SDK runtime applies the point’s report table (contract §5.3) to polled and
subscribed nodes alike, before a sample is published. See Reducing data volume.
Both kinds of point support a heartbeat (max_interval). A polled node publishes its next
poll. A subscribed node (monitored item) only notifies on a change, so a static node would
otherwise go silent after its first value; once max_interval has passed without a publish,
the runtime reads the node on demand through the ordinary read path, bounded by
operation_timeout. A failed or timed-out read publishes a bad sample and affects the link
status like a failed poll. The packaged config sets [connector] report = { max_interval = "30m" },
which keeps a device whose subscribed values never change available in Cumulocity.
3.6 Value mapping (map)
Section titled “3.6 Value mapping (map)”The SDK runtime applies the point’s map (contract §4.3) to polled and subscribed nodes
alike, after the transform, and maps a written value back before the connector writes the node.
Two cases are common. An enumeration node (an Int32 state) can be mapped to labels, and a
String node holding a number can become a number with map = { as = "number" }. See
Mapping values.
3.7 Structured values, array elements and other built-in types (Rust build)
Section titled “3.7 Structured values, array elements and other built-in types (Rust build)”An OPC UA structure (a custom data type) arrives as an ExtensionObject: an encoding id and a
binary body that has no meaning without the type’s layout. The connector compiles no type in. It
reads the layout from the server instead: once per session, for each node with field points,
it reads the variable’s DataType and that type’s DataTypeDefinition attribute (OPC UA 1.04),
along with the definitions of nested structures. A variable declared with the abstract
Structure (or BaseDataType) is resolved through its value: the encoding id of the structure it
holds leads to the concrete DataType by the HasEncoding reference. Only the types a point needs
are read. They are read again after every reconnect, so a server update is picked up.
Datapoint mode. Browse the server (for example with UaExpert), find the variable, and add one point per field you want:
[[device.point]]id = "pump1_speed"datatype = "float64"address = { node_id = "ns=2;s=Pump1.Status", field = "Speed" }
[[device.point]]id = "pump1_motor_current"datatype = "float32"address = { node_id = "ns=2;s=Pump1.Status", field = "Motor.Current" }Each field is published as an ordinary typed sample, so transform, map, report, meta
and every flow work as for any other point. Fields that share a node are served by one Read
per poll cycle and one monitored item.
- Selectable field types: Boolean, the integers, Float, Double, String, Enumerations (as
int32), and the built-in types below. The declareddatatypeis checked when the definition is read; a mismatch gives bad samples such asfield "Speed" is Double, point declares int32 (accepted: float64). - Structures, StructureWithOptionalFields and Unions are supported. An absent optional field or an inactive union member gives a bad sample that says so.
- Every other built-in type, arrays and nested structures before the selected field are skipped by their encoded length.
- Not supported: StructureWithSubtypedValues, multi-dimensional fields, XML or JSON encoded
bodies. Such a definition, or a server without
DataTypeDefinition(OPC UA 1.03), gives bad samples startingdata type definition unavailable. The device link is not degraded.
Raw mode. A mode = "raw" point on a structure publishes the encoded body as raw, with
addr.data_type and addr.encoding_id in namespace-URI form (nsu=urn:…;i=3000), so a flow
can decode types the connector cannot, such as those of a 1.03 server.
Array elements. index = n selects one element of an array variable, field = "A[n]" one
of an array field. An index beyond the current length gives bad samples naming the index; the
point recovers when the array grows. A server that ignores IndexRange and returns the whole
array (python-asyncua does) is handled: the connector selects the element itself.
Other built-in types. Values of these types are read as top-level variables, fields and
array elements; the declared datatype chooses the rendering:
| OPC UA type | datatype | Example value |
|---|---|---|
| DateTime | string / int64 | 2026-10-06T08:15:30.25Z / 1791274530250 (Unix ms) |
| LocalizedText | string | Betrieb (the text; the locale is dropped) |
| StatusCode | uint32 / string | 2150891520 / BadNodeIdUnknown |
| Guid | string | 72962b91-fa75-4ae6-8d28-b404dc7daf63 |
| NodeId, ExpandedNodeId | string | nsu=urn:acme:types;i=1001 |
| QualifiedName | string | 2:Speed |
| ByteString | bytes | deadbeef (hex) |
Writes are limited to the primitive types and String. The exact rendering rules and messages
are pinned by the vectors in doc/contract/test-vectors/opcua-struct/, which both builds run.
The C build’s fixed-size sample sets three limits (see impl/c/README.md): a bytes value longer
than 127 bytes and a raw structure body longer than 256 bytes are bad samples that name the limit,
never truncated, and a String or LocalizedText value is cut at 255 bytes as every C string is.
3.8 Subscription timing (sampling_interval)
Section titled “3.8 Subscription timing (sampling_interval)”A subscribed node is not sent the moment it changes. The server runs two timers:
- Sampling, per monitored item: how often the server checks the node’s value. The
connector requests the point’s effective
sampling_interval(contract §3.1): the point’s, else its device’s, else[connector]’s, else the point’s effectivepoll_interval. Each item queues up to the point’s effectivequeue_sizevalues between two publishes, withdiscardOldest = true(see “Queue size” below). - Publishing, per subscription (one per device): how often the server sends the queued changes. It is not configurable. The connector requests the fastest effective sampling interval among the device’s subscribed points.
A change therefore arrives up to about one sampling interval plus one publishing interval
after it happens. With the default poll_interval = "2s" and no sampling_interval, that is
about 4 s. To get changes faster without polling the other points faster:
[[device]]name = "opc-server-1"protocol_address = { endpoint = "opc.tcp://plc:4840/" }poll_interval = "10s" # polled nodes (subscribe = false)sampling_interval = "200ms" # subscribed nodes: sampled and published about every 200 mssampling_interval = "0" asks the server for its fastest rate. The server decides what that
means; it can be far more traffic, and more load on whatever the server reads the value from.
One point at "0" also makes the whole device’s subscription publish at the server’s fastest
rate. Use it only when you need it.
Queue size. A node that changes more often than the publishing interval produces several
values per publish. The monitored item keeps up to queue_size of them and drops the oldest
beyond that, so every change arrives as its own sample as long as no more than queue_size
happen between two publishes. The effective queue size is the point’s address.queue_size,
else the device’s protocol_address.queue_size, else [connection] queue_size, else 16. It
must be an integer from 1 to 65535. Anything else is refused with <place>: queue_size must be an integer from 1 to 65535, the same in both builds.
queue_size = 1 sends only the latest value per publish. That was the fixed behaviour before
the queue size could be set: the default changed from 1 to 16. Set [connection] queue_size = 1 to keep the previous behaviour. To publish fewer readings, prefer
report.min_interval or a deadband (§3.5), which act per point after the values have arrived.
The server may revise the queue size (python-asyncua grants what is asked; an async-opcua server caps it
at 10). When it does, both builds log an info line naming the device, the point, the requested
and the revised size, and keep what was granted.
Shared nodes. The Rust build creates one monitored item for all points on the same node and array element, with the fastest sampling interval and the largest queue size among them. The C build creates one item per point, with that point’s own values.
Many points. Both builds create a device’s monitored items in requests of at most 500, so a
device with thousands of subscribed points subscribes. The Rust client also decodes arrays of up
to 65535 elements, where async-opcua’s default limit of 1000 used to refuse such a device with
BadDecodingError. A refused item has a different effect in each build:
- Rust: any refused item, or any failed request, deletes the device’s subscription, and all of its points are polled.
- C: only the refused points are polled. The subscription is dropped only when the server accepted no item at all.
Many polled points. Both builds read a device’s due points in Read requests of at most 500
nodes, or the server’s OperationLimits.MaxNodesPerRead when that is lower. The limit is read
once per session; a limit below 500 is logged as an info line naming the device. A pass over
30,000 points thus takes 60 requests. A request the server refuses makes only its own points
bad. A request that times out or finds the connection gone ends the pass: no further request
is sent, and the link goes down at once. The points read by earlier requests of the pass are
published. Of the others, the C build publishes the first as bad, with the status as its error;
the Rust build publishes all of them as bad when the failed request was the pass’s first, and
none otherwise.
The C build buffers pushed values between two runtime ticks in a queue per device. The queue
starts with room for twice the device’s subscribed points, so every point’s initial value
fits. It doubles when a burst fills it, up to 65536 entries or four times the points, whichever
is larger. Past that it drops the newest value and logs a warn line naming the device and the
bound.
The server may grant other rates than requested. When the revised sampling interval of an item,
or the revised publishing interval of a subscription, differs from the requested one, both
builds log an info line naming the device (and the point), the requested and the revised
value. The connector keeps what the server granted.
The publishing interval also paces the subscription keep-alive that the connector’s
check_subscription (SDK) relies on to notice a dead session: it
allows about publishing interval × 20 + publishing interval without a publish. That is one
reason the publishing interval is not used as a rate limit. To limit how often a point is
published, use report.min_interval (§3.5): it is per point and does not delay the first change
after a quiet spell.
3.9 Namespace tables (namespaces)
Section titled “3.9 Namespace tables (namespaces)”A node id names its namespace by index: the 3 in ns=3;i=1234 is a position in the
server’s NamespaceArray (i=2255). OPC UA fixes only index 0 (http://opcfoundation.org/UA/).
A server may number the others differently after a firmware or project update, and two
instances of one device type may number them differently from the start (OPC 10000-5 §6.3.1).
An id then names another node, or none — and a point that reads another node publishes plausible
but wrong values.
A namespace table records the NamespaceArray the ids were written against, as a full copy
in index order (what UaExpert’s namespace view shows):
[[device]]name = "plc-7"protocol_address = { endpoint = "opc.tcp://plc:4840/", namespaces = [ "http://opcfoundation.org/UA/", "urn:plc:server", "urn:acme:plc" ] }
[[device.point]] id = "boiler_temp" datatype = "float64" address = { node_id = "ns=2;i=1234" } # urn:acme:plc, wherever this server lists it| Where | Applies to |
|---|---|
protocol_address.namespaces | The device’s own points: inline points, and library points whose address the device overrides. |
[library] namespaces | The points of that library (contract §3.4); a library without one uses the device’s. |
Rules:
- Entry 0 must be
http://opcfoundation.org/UA/, and a point’s namespace index must be listed:point '<id>': namespace index 5 is not in the namespace table (3 entries). Both are configuration errors. ns=0(and an id withoutns=) is never translated.- Without a table nothing changes: the index is the server’s, and no namespace is read.
Translation. After every session activation (first connect and every reconnect), before the
device’s first read, write or monitored item, the connector reads NamespaceArray once and
translates each point that has a table: index → URI in the table → the index of that URI on the
server (exact, case-sensitive; the first occurrence). The point is then read, written and
subscribed there; the identifier is used unchanged. Points that translate to the same node share
one monitored item. Each namespace the server lists at another index than the table is logged
once per session at info, in both builds:
plc-7: namespace urn:acme:plc local 2 -> server 3Samples, status and describe keep the address as configured (addr.node_id is ns=2;i=1234
before and after a renumbering).
Why once per session. A server “shall not modify or delete entries of the namespace table
while any client has an open session”; it may only add entries (OPC 10000-5 §6.3.1). OPC UA
defines no event for a changed NamespaceArray (the optional UrisVersion property is only
increased), and it asks clients to check cached indexes “when starting a session”. A translation
therefore stays valid for its session, and a renumbering is picked up on reconnect.
Failures are per point.
| Case | Sample error |
|---|---|
| The table’s URI is not in the server’s array | namespace not found: <uri> |
NamespaceArray cannot be read | namespace array unavailable: <status> (every point with a table) |
| The translated node does not exist | as for any missing node (BadNodeIdUnknown) |
Such a point publishes bad samples, refuses writes and gets no monitored item. Connecting and subscribing still succeed; for the link status a polled point counts like any other bad point, and a subscribed one’s bad samples do not count, as for every pushed sample. An untranslated point is tried again: a polled one before its next poll, a subscribed one every 30 s, when it then gets its monitored item. That also picks up a namespace the server adds while connected (the C build renews its session for it: open62541 maps namespace indexes with a table it builds per session). Points translated in a session are not translated again until the next one.
3.10 Method calls (call_method)
Section titled “3.10 Method calls (call_method)”A point with access = "call_method" (contract §3.1) is an OPC UA method, called with the
call-method verb (contract §6.6). It is never read, subscribed or written, and declares no
datatype. Both builds.
[[device.point]]id = "multiply"access = "call_method"address = { object_id = "ns=2;s=Machine", method_id = "ns=2;s=Machine.Multiply", inputs = ["int32", "int32"] }| Key | Meaning |
|---|---|
object_id | Required. The object (or object type) the method is called on, as a textual node id. |
method_id | Required. The method, as a textual node id. |
inputs | The datatypes of the input arguments, in order: bool, int8 … uint64, float32, float64 or string (contract §4). Omitted: the method takes no arguments. |
The variable keys (node_id, namespace, identifier, field, index, queue_size) do not
apply to a method point, and the method keys apply to nothing else; either mix is a
configuration error. The argument types are declared, not read from the method’s
InputArguments property: the configuration means the same whatever the server exposes, and a
connect costs no extra browse.
Arguments. A request must carry exactly as many arguments as inputs lists. Each is
converted to its datatype by the rules a write uses; a 64-bit integer may also be a decimal
string. A count or value that does not fit fails the command before anything is sent.
Outputs. outputs lists the method’s output arguments in order. A scalar is rendered as in
a sample’s value (a 64-bit integer beyond ±2^53−1 as a decimal string), an array of scalars
as a JSON array, and anything else — a structure, a node id, a nested array — as null. An
output that cannot be rendered never fails the call: the method has already run.
Failure reasons, identical in both builds:
| Reason | When |
|---|---|
device <name> is not connected | The device has no session. |
| the namespace reason (§3.9) | object_id or method_id could not be translated. |
expected <n> arguments, got <m> | The argument count does not match inputs. |
argument <k>: <why> | Argument k (1-based) does not fit its datatype. |
call failed: <status> | The Call service failed, e.g. BadTimeout after request_timeout_s. |
call rejected: argument <k>: <status> | The server rejected argument k, e.g. BadTypeMismatch. |
call rejected: <status> | The method’s own status, e.g. BadInvalidState, BadNotExecutable, BadUserAccessDenied. |
A method call is never retried. A call that times out may still have run on the server.
Namespace tables. With a namespace table (§3.9), object_id and method_id are each
translated like a variable’s node id, and a call fails with the reason of whichever could not be.
4. The PKI directory
Section titled “4. The PKI directory”4.1 Layout
Section titled “4.1 Layout”<pki_dir>/ own/certs/cert.der application instance certificate own/private/key.pem its private key (directory 0700, file 0600) trusted/certs/ trusted server certificates (pinned) and trusted CA certificates trusted/crl/ CRLs of the CAs in trusted/certs issuers/certs/ intermediate CAs: complete a chain, never trusted on their own issuers/crl/ CRLs of the CAs in issuers/certs rejected/certs/ server certificates that failed validation- Files are recognised by content — a DER certificate or CRL, or PEM text with any number of
CERTIFICATE/X509 CRLblocks — never by name or extension. A file that does not parse is skipped with a warning naming it; the other files still count. - Files the connector or
tedge-dot pkiwrites are named<CN>_<thumbprint>.der(the common name with every character but ASCII letters, digits,-and.replaced by_; the SHA-1 thumbprint in lower-case hex) and CRLs<thumbprint of the CRL>.crl. Writes go through a temporary file andrename(2). - The trust lists are read again on every connect attempt: a change takes effect on the device’s next (re)connect, without a reload.
- A configuration whose devices all use
Noneneither creates nor reads the directory.
4.2 Application instance certificate
Section titled “4.2 Application instance certificate”When at least one device is secured, the connector loads the certificate at configure time:
the configured certificate/private_key, else own/, else — when neither file exists and
create_certificate is true — a new one. Generation runs under an exclusive flock on
own/.lock, so connectors sharing a directory generate one certificate between them.
A generated certificate is self-signed, RSA 2048 bits, SHA-256, with a random serial,
valid for 5 years, with key usage digitalSignature/nonRepudiation/keyEncipherment/
dataEncipherment/keyCertSign, extended key usage serverAuth/clientAuth, and the subject
alternative names application_uri (first) followed by the machine’s host name.
The certificate is refused when its key does not match, when the key file is readable by group
or others, or when application_uri is not one of its SANs. A certificate that cannot be
obtained is not a configuration error: unsecured devices work, and each secured device
stays disconnected with the reason application certificate: …. The same applies while the
certificate is expired. The connector warns at configure time and then at most once a day while
the certificate expires within 30 days. A renewed certificate is picked up on reload or restart.
5. Connecting
Section titled “5. Connecting”5.1 Endpoint selection
Section titled “5.1 Endpoint selection”An anonymous device without security dials endpoint directly. Every other device first
calls GetEndpoints on endpoint (over a channel without security, which servers allow for
discovery) and selects the endpoint with the device’s policy and mode that offers a user token
policy for its identity, preferring the highest securityLevel. The selected endpoint’s URL is
replaced in full by the configured endpoint — host, port and resource path: the session
always goes to the configured address (servers behind NAT advertise names the client cannot
reach, and the UA-.NETStandard reference server advertises opc.tcp://0.0.0.0:<port>, which
nothing can dial), and host-name checks use the configured host.
No endpoint with the policy and mode → no matching endpoint: the server offers no <policy>/<mode> endpoint (it offers <policy>/<mode>, …) (sorted, deduplicated). A policy this connector does
not implement is listed by its full URI rather than dropped, so a server offering only, say,
the ECC policies explains itself instead of appearing to offer nothing. Such endpoints exist, but none
accepts the identity → identity unsupported: …. The token policy must be listed, for an
anonymous identity too: an endpoint that lists no token policies is not usable, since neither
client library can activate a session on it.
5.2 Server certificate validation
Section titled “5.2 Server certificate validation”Before dialling a secured endpoint, the connector validates the certificate the endpoint advertises; the session validates the certificate of the channel again the same way. Steps, in order:
- Key length for the policy is checked first, before any trust lookup: 2048–4096 bits
(1024–2048 for the deprecated policies). A key that is too short cannot be made acceptable by
trusting the certificate, so reporting it as untrusted — and advising
tedge-dot pki trust— would send the operator in a circle. Such a certificate is refused ascertificate invalid:naming the key size and the policy, and is not copied torejected/certs, which is a list of certificates an operator could choose to trust. (The UA-.NETStandard reference server auto-generates a 1024-bit certificate, so this is not hypothetical.) - Pinned. A certificate identical to one in
trusted/certsis trusted. - Chain. Otherwise the path is built from the certificate through CAs (basicConstraints
cA, subject = issuer, valid signature: RSA PKCS#1 v1.5 with SHA-1/256/384/512 or RSA-PSS with SHA-256/384/512) taken fromtrusted/certsthenissuers/certs, until a CA fromtrusted/certsis reached. No path, a path longer than 8, or a self-issued CA that is only inissuers/→ untrusted. - Revocation. For every certificate on the path, the CRLs issued (named and signed) by its
issuer are looked up in both
crldirectories. None → revocation unknown. Its serial listed → revoked. A CA outside its validity period → issuer time invalid. - Validity period of the certificate.
- Host name: one of the certificate’s SANs after the first equals the configured host (case-insensitive).
- Application URI: the first SAN equals the server’s advertised
applicationUri.
An untrusted certificate (steps 1–2) is copied to rejected/certs unless it is already there.
rejected/certs is a list for the operator to review (OPC UA Part 12), not a deny list: a
certificate that is pinned or chains to a trusted CA is trusted even when a copy is still there,
as happens when a server was first seen before its CA was trusted. To stop trusting a single
certificate that a trusted CA issued, revoke it in that CA’s CRL.
A server may advertise its certificate chain (leaf first, then its CAs). Both builds validate
the first certificate; the CAs it sends are not used, so intermediates belong in
issuers/certs.
5.3 Reasons (link status)
Section titled “5.3 Reasons (link status)”A security failure leaves the device disconnected with a reason starting with one of:
| Prefix | Status codes / cause |
|---|---|
certificate untrusted: | BadCertificateUntrusted, BadSecurityChecksFailed while validating the server certificate; includes the thumbprint, the subject and trust it with `tedge-dot pki trust <8 hex>` . |
application certificate rejected: | The same status codes, but after §5.2 passed (or was skipped by §5.4): the refusal is then the server’s judgement of our certificate, not ours of theirs. Names our own thumbprint and says to hand tedge-dot pki export to the server administrator. Only the ordering tells the two apart — the wire says the same thing both ways. |
certificate invalid: | BadCertificateTimeInvalid, BadCertificateIssuerTimeInvalid, BadCertificateHostNameInvalid, BadCertificateUriInvalid, BadCertificateInvalid, BadCertificatePolicyCheckFailed, BadCertificateUseNotAllowed, BadCertificateIssuerUseNotAllowed, BadCertificateChainIncomplete |
certificate revoked: | BadCertificateRevoked, BadCertificateIssuerRevoked, BadCertificate[Issuer]RevocationUnknown (the text says “revocation unknown” and to add the CRL, an empty one if the CA revoked nothing) |
no matching endpoint: | §5.1 |
identity rejected: | BadIdentityTokenRejected, BadIdentityTokenInvalid, BadUserAccessDenied |
identity unsupported: | §5.1 |
plaintext password refused: | §6.2 |
application certificate: | §4.2 |
Consumers match the prefix only; the text after it is for people. The link status is published
when the status changes, so the reason shown is that of the failure that made the device
disconnected.
5.4 trust_any_server_certificate
Section titled “5.4 trust_any_server_certificate”Skips §5.2 entirely (messages are still signed/encrypted as configured) and writes nothing to the PKI directory. The connector logs a warning on every connect.
6. Identity
Section titled “6. Identity”6.1 Tokens
Section titled “6.1 Tokens”The token uses the selected endpoint’s user token policy. A username token is encrypted as the policy (or, when it names none, the channel’s policy) requires; an X.509 token is signed with the user key using the policy’s algorithm.
6.2 Plaintext passwords
Section titled “6.2 Plaintext passwords”A password is unprotected, and refused — plaintext password refused: … — unless
allow_plaintext_password is true (then a warning is logged on every connect), when:
- the channel has no message security (
none), whatever the username token policy says. OPC UA Part 4 Table 193 lets such a token be encrypted, but to a server certificate that nothing on anonechannel authenticates (neither client library validates it there), so any server the device is pointed at could decrypt it; - the channel is signed only (
sign) and the token policy explicitly namesNone: the password is then in clear on the wire.
On sign_and_encrypt, or with a token policy that names a real policy on sign, the server
certificate was validated (§5.2) and the password is protected.
7. Status
Section titled “7. Status”Link status info (contract §8):
{ "endpoint": "opc.tcp://plc:4840/", "security_policy": "Basic256Sha256", "security_mode": "sign_and_encrypt", "server_certificate": "trusted", "server_thumbprint": "a1b2…" }server_certificate ("trusted" or "not_verified") and server_thumbprint are present once
known for a secured device. The object never holds a credential.
8. tedge-dot pki
Section titled “8. tedge-dot pki”tedge-dot pki <action> [--pki-dir <dir>] [-c|--config <file>] [--json] works offline (no
broker, no server). The PKI directory is --pki-dir, else the pki_dir of --config, else of
/etc/tedge/plugins/ot/opcua.toml when it exists, else the default. The connection settings
(application_name, application_uri, certificate, private_key) come from the same file.
Exit status: 0 success, 1 usage or input error, 2 the named certificate does not exist.
| Action | Does |
|---|---|
show | The application certificate: paths, subject, issuer, thumbprint, validity, application URI, host names, whether the URI matches application_uri. |
export [--pem] [-o FILE] | Write the certificate (DER by default), never the key. |
create [--application-uri URI] [--hostname NAME]... [--days N] [--force] | Generate the certificate in own/ (§4.2). An existing one is refused unless --force, which keeps it as cert.der.<unix time> / key.pem.<unix time>. |
list [trusted|issuers|rejected] | Certificates with group, thumbprint, subject, expiry, whether a CA and (for a CA) whether its CRL is present. |
trust <thumbprint|file> | Move a rejected certificate to trusted/, or import every certificate of a file there (and out of rejected/). |
reject <thumbprint> | Move a trusted certificate to rejected/. Warns when the certificate is still trusted through a CA (revoke it instead). |
remove <thumbprint> [--group G] | Delete a certificate. |
add-issuer <file> | Import CA certificates into issuers/ (non-CA certificates are refused). |
add-crl <file> | Store each CRL next to the CA (trusted or issuers) that issued it; a CRL of an unknown CA is refused. |
A thumbprint is matched case-insensitively and may be a prefix of at least 8 hex digits; a
prefix matching several certificates is an error listing them. Run as root on a PKI directory
another user owns (the packaged one belongs to tedge), the command works as that user
(effective user and group): what it writes belongs to the owner, and a symlink placed in the
directory cannot redirect a write to a file only root may change. The file an action reads
and the export --output file are accessed as root.
JSON field names (both builds; keys unordered):
show/create:certificate,private_key,subject,issuer,thumbprint,not_before,not_after,application_uri(ornull),hostnames,configured_application_uri,uri_matches, and forcreatecreated.list:pki_dir,certificates[]of{group, thumbprint, subject, not_after, ca, crl, file}(crlisnullfor a non-CA).trust/reject/remove/add-issuer:action,certificates[](as above).add-crl:action,crls[]of{file}.export:thumbprint,format, andfile(with--output) orpem.
Subjects print as CN=…, O=… in encoding order; times as YYYY-MM-DDTHH:MM:SSZ.
9. Packaging
Section titled “9. Packaging”The packages create /var/lib/tedge-dot/opcua/pki (owner tedge, mode 0750) with
own/private (0700), generate nothing, and leave the contents alone on upgrade and removal. A
Debian purge deletes the directory.
10. Tests
Section titled “10. Tests”| Layer | Where |
|---|---|
| PKI vectors (both builds, same scenarios) | connector-opcua/tests/pki.rs, impl/c/tests/opcua_pki.c over genpki.py |
| Configuration rules | connector-opcua/src/config.rs, impl/c/tests/config.c; secrets property test connector-opcua/tests/secrets.rs |
| Secured sessions (in-process server) | connector-opcua/tests/security.rs |
| Conformance over secured channels | connectors/opcua/conformance-secure.toml, conformance-secure-c.toml |
| End to end | connectors/opcua/tests/opcua_security_e2e.robot (docker-compose.secure.yaml) |
| Namespace tables (§3.9) | connector-opcua/src/namespaces.rs, connector-opcua/tests/namespaces.rs, impl/c/tests/opcua_namespaces.c; end to end connectors/opcua/tests/opcua_namespace_e2e.robot (docker-compose.namespaces.yaml) |
| Method calls (§3.10) | connector-opcua/src/lib.rs, connector-opcua/tests/methods.rs (in-process server), impl/c/tests/config.c; end to end connectors/opcua/tests/opcua_e2e.robot (device machine) |
| Interop, third-party server | connectors/opcua/interop/tests/opcua_interop.robot (just test-interop opcua, and -c) |
tedge-dot pki | impl/rust/tests/pki_cli.rs, impl/c/tests/opcua_pki_cli.sh, parity impl/c/ci/pki-parity.sh |
| Fuzzing | connector-opcua/fuzz (pki_files) |
Every layer above the interop one runs against a server this project wrote, so a misreading of
the specification is invisible to it. The interop suite exists to catch that: it runs both
implementations against the OPC Foundation UA-.NETStandard reference stack (packaged by
php-opcua/uanetstandard-test-suite, MIT). It is a separate CI job from e2e, so an upstream
regression cannot fail the suites that gate our own behaviour, and it is the only place where
Aes128_Sha256_RsaOaep, Basic256 and Basic128Rsa15 are exercised on the wire.