Setup:
Net Base Services (NBS), TCP_Client + TLSContext, ePurpose := CLIENT_SIDE, connecting to a third-party embedded device (Velux KLF200 home automation gateway) whose factory-installed TLS certificate expired on 2026-07-12 and has no user-facing renewal path. Tested on both CODESYS Control for Raspberry Pi SL (3.5.19.0) and CODESYS Control Win V3 x64, with identical results on both.
Goal:
Accept the peer's expired certificate via a custom ICertificateVerifier implementation, since the certificate itself can never be replaced.
Wired in via tlsContext.itfCertVerifer := certVerifier at declaration time, alongside itfTLSContext := tlsContext set at TCP_Client's own declaration (per an earlier forum thread here on ensuring inline start-values on FB-typed variables are actually honored when nested one level deep - that fix was needed and worked correctly for getting the handshake to start at all).
Observed:
- VerifyCertificate is confirmed called exactly once per connection attempt (via the call counter above).
- eCurState passed in = 0x709 = ERR_CERT_HAS_EXPIRED - correctly reflecting the actual state of the peer's certificate.
- Packet capture confirms the full TLS 1.2 handshake completes correctly at the wire level: ClientHello -> ServerHello -> Certificate -> ServerKeyExchange -> ServerHelloDone -> our ClientKeyExchange/ChangeCipherSpec/Finished -> server's ChangeCipherSpec/Finished. Both sides successfully complete the cryptographic handshake.
- Despite this, TCP_Client.Upgrade() subsequently returns NBS.ERROR.CONNECTION_ERROR, and the connection is torn down with a plain TCP FIN (not a TLS alert) roughly 7-8ms after our side ACKs the server's Finished message.
- TCP_Client.eErrorID (inherited from LCon) remains NO_ERROR throughout - the failure is only visible via Upgrade()'s return value.
- Returning 0 (ERR_OK/ERR_CERT_OK - confirmed via the CmpErrors2 Interfaces documentation that these are literally the same value) makes no difference.
- Echoing eCurState back unchanged (on the theory that this callback might work like mbedTLS's native verify callback, where the incoming state is a flags value to be cleared/modified and returned, rather than paired with a separate fixed "accept" sentinel) also makes no difference.
- Identical result across udiVerificationMode := 0, 1, and 2.
- Importing the peer's certificate into the runtime's trusted certificate store via cert-import trusted makes no difference either.
- With itfCertVerifer left at its default (no custom verifier) and udiVerificationMode := 2, the underlying engine sends its own fatal TLS alert (handshake_failure) immediately after receiving the peer's Certificate message - suggesting built-in validation may reject at the record layer independent of whether a custom verifier is even present.
Questions:
1. Is there an additional step required for a custom ICertificateVerifier to actually override an ERR_CERT_HAS_EXPIRED (or more generally, any ERR_CERT_*) rejection, or is certificate validation enforced unconditionally for a CLIENT_SIDE TLSContext regardless of what the verifier returns?
2. Is 0/ERR_OK genuinely the correct "accept" return value for VerifyCertificate, or does it expect something else (a specific bit pattern, a different constant, a value that must be computed from hCert itself)?
3. Is there a supported way to connect to a peer presenting a certificate that is both self-signed and expired, short of the peer issuing a new certificate?
Happy to share the full packet captures or additional test combinations if useful. Also worth noting for anyone finding this thread later: a maintained Node-RED library for this same device (node-red-contrib-velux-klf200, forked to handle this exact expired-certificate situation) solves it by disabling built-in TLS validation entirely (rejectUnauthorized: false) and doing fingerprint-based pinning by hand after the handshake completes, rather than relying on a validation callback - which may be the more realistic approach here too if ICertificateVerifier genuinely can't override this class of rejection.
Thanks in advance
/JΓΈrgen
Disclosure: most of the CODESYS code and this investigation was developed with the help of Claude (Anthropic's AI assistant) - the state machine design, SLIP/checksum implementation, and debugging methodology (including the packet capture analysis) were worked through in an extended back-and-forth with it. Posting here because we've run out of self-serviceable options and this now looks like it needs input from someone with visibility into NBS's actual implementation.
Follow-up: working workaround found (routing TLS outside CODESYS)
For anyone finding this thread later with the same problem (a third-party device presenting a self-signed and/or expired certificate that ICertificateVerifier/udiVerificationMode won't let you connect through) - we never got a working fix within NBS itself, but found a practical workaround worth sharing.
Approach: run a local TLS-terminating proxy on the same host as the CODESYS runtime, and have CODESYS talk plain, unencrypted TCP to it instead of connecting to the remote device directly over TLS:
We used stunnel (client mode), with certificate verification explicitly disabled at that layer (verifyChain = no, verifyPeer = no) - a simple, well-documented one-line option, unlike anything we could get working inside NBS. Running it as a systemd service with Restart=always means it survives reboots and recovers automatically if the proxy or the remote device drops.
On the CODESYS side, this required no changes beyond pointing TCP_Client at 127.0.0.1 instead of the device's real IP, and removing the TLSContext/ICertificateVerifier code entirely - the rest of our application-layer logic (framing, checksums, etc.) was completely unaffected, since none of that ever depended on where the TLS termination happened.
Confirmed working end-to-end against a real device with an expired self-signed certificate, running on CODESYS Control for Raspberry Pi SL (Linux). We haven't yet set this up on Windows - stunnel does have Windows builds available, so the same general approach should be possible there too, but that combination isn't tested or confirmed by us at this point.
Caveat: this only makes sense when the proxy and the CODESYS runtime are on the same trusted host (in our case, both on the same Raspberry Pi) - don't expose the proxy's plaintext side on a reachable network interface, since that would create an unauthenticated plaintext path to the device.
The original question - whether there's a supported way to override an ERR_CERT_HAS_EXPIRED/self-signed rejection from within NBS's own TLSContext/ICertificateVerifier - remains open as far as we know. Still happy to hear from anyone who's solved that particular piece, since the workaround above is a practical detour rather than an actual answer to the original question.
If you would like to refer to this comment somewhere else in this project, copy and paste the following link:
Setup:
Net Base Services (NBS),
TCP_Client+TLSContext,ePurpose := CLIENT_SIDE, connecting to a third-party embedded device (Velux KLF200 home automation gateway) whose factory-installed TLS certificate expired on 2026-07-12 and has no user-facing renewal path. Tested on both CODESYS Control for Raspberry Pi SL (3.5.19.0) and CODESYS Control Win V3 x64, with identical results on both.Goal:
Accept the peer's expired certificate via a custom
ICertificateVerifierimplementation, since the certificate itself can never be replaced.Implementation:
Wired in via
tlsContext.itfCertVerifer := certVerifierat declaration time, alongsideitfTLSContext := tlsContextset atTCP_Client's own declaration (per an earlier forum thread here on ensuring inline start-values on FB-typed variables are actually honored when nested one level deep - that fix was needed and worked correctly for getting the handshake to start at all).Observed:
-
VerifyCertificateis confirmed called exactly once per connection attempt (via the call counter above).-
eCurStatepassed in =0x709=ERR_CERT_HAS_EXPIRED- correctly reflecting the actual state of the peer's certificate.- Packet capture confirms the full TLS 1.2 handshake completes correctly at the wire level: ClientHello -> ServerHello -> Certificate -> ServerKeyExchange -> ServerHelloDone -> our ClientKeyExchange/ChangeCipherSpec/Finished -> server's ChangeCipherSpec/Finished. Both sides successfully complete the cryptographic handshake.
- Despite this,
TCP_Client.Upgrade()subsequently returnsNBS.ERROR.CONNECTION_ERROR, and the connection is torn down with a plain TCP FIN (not a TLS alert) roughly 7-8ms after our side ACKs the server's Finished message.-
TCP_Client.eErrorID(inherited from LCon) remainsNO_ERRORthroughout - the failure is only visible viaUpgrade()'s return value.- Returning
0(ERR_OK/ERR_CERT_OK- confirmed via the CmpErrors2 Interfaces documentation that these are literally the same value) makes no difference.- Echoing
eCurStateback unchanged (on the theory that this callback might work like mbedTLS's native verify callback, where the incoming state is a flags value to be cleared/modified and returned, rather than paired with a separate fixed "accept" sentinel) also makes no difference.- Identical result across
udiVerificationMode := 0,1, and2.- Importing the peer's certificate into the runtime's trusted certificate store via
cert-import trustedmakes no difference either.- With
itfCertVeriferleft at its default (no custom verifier) andudiVerificationMode := 2, the underlying engine sends its own fatal TLS alert (handshake_failure) immediately after receiving the peer's Certificate message - suggesting built-in validation may reject at the record layer independent of whether a custom verifier is even present.Questions:
1. Is there an additional step required for a custom
ICertificateVerifierto actually override anERR_CERT_HAS_EXPIRED(or more generally, anyERR_CERT_*) rejection, or is certificate validation enforced unconditionally for a CLIENT_SIDETLSContextregardless of what the verifier returns?2. Is
0/ERR_OKgenuinely the correct "accept" return value forVerifyCertificate, or does it expect something else (a specific bit pattern, a different constant, a value that must be computed fromhCertitself)?3. Is there a supported way to connect to a peer presenting a certificate that is both self-signed and expired, short of the peer issuing a new certificate?
Happy to share the full packet captures or additional test combinations if useful. Also worth noting for anyone finding this thread later: a maintained Node-RED library for this same device (node-red-contrib-velux-klf200, forked to handle this exact expired-certificate situation) solves it by disabling built-in TLS validation entirely (
rejectUnauthorized: false) and doing fingerprint-based pinning by hand after the handshake completes, rather than relying on a validation callback - which may be the more realistic approach here too ifICertificateVerifiergenuinely can't override this class of rejection.Thanks in advance
/JΓΈrgen
Disclosure: most of the CODESYS code and this investigation was developed with the help of Claude (Anthropic's AI assistant) - the state machine design, SLIP/checksum implementation, and debugging methodology (including the packet capture analysis) were worked through in an extended back-and-forth with it. Posting here because we've run out of self-serviceable options and this now looks like it needs input from someone with visibility into NBS's actual implementation.
Follow-up: working workaround found (routing TLS outside CODESYS)
For anyone finding this thread later with the same problem (a third-party device presenting a self-signed and/or expired certificate that
ICertificateVerifier/udiVerificationModewon't let you connect through) - we never got a working fix within NBS itself, but found a practical workaround worth sharing.Approach: run a local TLS-terminating proxy on the same host as the CODESYS runtime, and have CODESYS talk plain, unencrypted TCP to it instead of connecting to the remote device directly over TLS:
We used
stunnel(client mode), with certificate verification explicitly disabled at that layer (verifyChain = no,verifyPeer = no) - a simple, well-documented one-line option, unlike anything we could get working inside NBS. Running it as a systemd service withRestart=alwaysmeans it survives reboots and recovers automatically if the proxy or the remote device drops.On the CODESYS side, this required no changes beyond pointing
TCP_Clientat127.0.0.1instead of the device's real IP, and removing theTLSContext/ICertificateVerifiercode entirely - the rest of our application-layer logic (framing, checksums, etc.) was completely unaffected, since none of that ever depended on where the TLS termination happened.Confirmed working end-to-end against a real device with an expired self-signed certificate, running on CODESYS Control for Raspberry Pi SL (Linux). We haven't yet set this up on Windows -
stunneldoes have Windows builds available, so the same general approach should be possible there too, but that combination isn't tested or confirmed by us at this point.Caveat: this only makes sense when the proxy and the CODESYS runtime are on the same trusted host (in our case, both on the same Raspberry Pi) - don't expose the proxy's plaintext side on a reachable network interface, since that would create an unauthenticated plaintext path to the device.
The original question - whether there's a supported way to override an
ERR_CERT_HAS_EXPIRED/self-signed rejection from within NBS's ownTLSContext/ICertificateVerifier- remains open as far as we know. Still happy to hear from anyone who's solved that particular piece, since the workaround above is a practical detour rather than an actual answer to the original question.