<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en" xmlns="http://www.w3.org/2005/Atom"><title>Recent posts to ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</title><link href="https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/" rel="alternate"/><link href="https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/feed.atom" rel="self"/><id>https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/</id><updated>2026-08-06T20:51:34.633000Z</updated><subtitle>Recent posts to ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</subtitle><entry><title>ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</title><link href="https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/?limit=25#a727" rel="alternate"/><published>2026-08-06T20:51:34.633000Z</published><updated>2026-08-06T20:51:34.633000Z</updated><author><name>baltzer</name><uri>https://forge.codesys.com/u/baltzer/</uri></author><id>https://forge.codesys.com114577e160d225eb1b4f6c4666083526389e0234</id><summary type="html">&lt;div class="markdown_content"&gt;&lt;p&gt;&lt;strong&gt;Follow-up: working workaround found (routing TLS outside CODESYS)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For anyone finding this thread later with the same problem (a third-party device presenting a self-signed and/or expired certificate that &lt;code&gt;ICertificateVerifier&lt;/code&gt;/&lt;code&gt;udiVerificationMode&lt;/code&gt; won't let you connect through) - we never got a working fix within NBS itself, but found a practical workaround worth sharing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Approach:&lt;/strong&gt; 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:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;CODESYS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TCP&lt;/span&gt;&lt;span class="o"&gt;--&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="o"&gt;:&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;stunnel&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;TLS&lt;/span&gt;&lt;span class="o"&gt;--&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;:&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;We used &lt;code&gt;stunnel&lt;/code&gt; (client mode), with certificate verification explicitly disabled at that layer (&lt;code&gt;verifyChain = no&lt;/code&gt;, &lt;code&gt;verifyPeer = no&lt;/code&gt;) - a simple, well-documented one-line option, unlike anything we could get working inside NBS. Running it as a systemd service with &lt;code&gt;Restart=always&lt;/code&gt; means it survives reboots and recovers automatically if the proxy or the remote device drops.&lt;/p&gt;
&lt;p&gt;On the CODESYS side, this required no changes beyond pointing &lt;code&gt;TCP_Client&lt;/code&gt; at &lt;code&gt;127.0.0.1&lt;/code&gt; instead of the device's real IP, and removing the &lt;code&gt;TLSContext&lt;/code&gt;/&lt;code&gt;ICertificateVerifier&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 - &lt;code&gt;stunnel&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;The original question - whether there's a supported way to override an &lt;code&gt;ERR_CERT_HAS_EXPIRED&lt;/code&gt;/self-signed rejection from &lt;em&gt;within&lt;/em&gt; NBS's own &lt;code&gt;TLSContext&lt;/code&gt;/&lt;code&gt;ICertificateVerifier&lt;/code&gt; - 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.&lt;/p&gt;&lt;/div&gt;</summary></entry><entry><title>ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</title><link href="https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/?limit=25#b54d" rel="alternate"/><published>2026-07-26T09:53:51.753000Z</published><updated>2026-07-26T09:53:51.753000Z</updated><author><name>baltzer</name><uri>https://forge.codesys.com/u/baltzer/</uri></author><id>https://forge.codesys.com48a0a3ea1b956f833eba9afe7cc5e714e420ce35</id><summary type="html">&lt;div class="markdown_content"&gt;&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt;&lt;br/&gt;
Net Base Services (NBS), &lt;code&gt;TCP_Client&lt;/code&gt; + &lt;code&gt;TLSContext&lt;/code&gt;, &lt;code&gt;ePurpose := CLIENT_SIDE&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt;&lt;br/&gt;
Accept the peer's expired certificate via a custom &lt;code&gt;ICertificateVerifier&lt;/code&gt; implementation, since the certificate itself can never be replaced.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implementation:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;FUNCTION_BLOCK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FB_MyCertVerifier&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;IMPLEMENTS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NBS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ICertificateVerifier&lt;/span&gt;
&lt;span class="nf"&gt;VAR&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;udiCallCount&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UDINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;aeCurStateLog&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ARRAY&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;0..9&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;OF&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NBS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RTS_IEC_RESULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;END_VAR&lt;/span&gt;

&lt;span class="k"&gt;METHOD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VerifyCertificate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UDINT&lt;/span&gt;
&lt;span class="n"&gt;VAR_INPUT&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;hCert&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NBS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RTS_IEC_HANDLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;eCurState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NBS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RTS_IEC_RESULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;END_VAR&lt;/span&gt;

&lt;span class="k"&gt;IF&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;udiCallCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THEN&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;aeCurStateLog&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;udiCallCount&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;eCurState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;END_IF&lt;/span&gt;
&lt;span class="n"&gt;udiCallCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;udiCallCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;VerifyCertificate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;also&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;tried&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VerifyCertificate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;eCurState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Wired in via &lt;code&gt;tlsContext.itfCertVerifer := certVerifier&lt;/code&gt; at declaration time, alongside &lt;code&gt;itfTLSContext := tlsContext&lt;/code&gt; set at &lt;code&gt;TCP_Client&lt;/code&gt;'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).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Observed:&lt;/strong&gt;&lt;br/&gt;
- &lt;code&gt;VerifyCertificate&lt;/code&gt; is confirmed called exactly once per connection attempt (via the call   counter above).&lt;br/&gt;
- &lt;code&gt;eCurState&lt;/code&gt; passed in = &lt;code&gt;0x709&lt;/code&gt; = &lt;code&gt;ERR_CERT_HAS_EXPIRED&lt;/code&gt; - correctly reflecting the actual state of the peer's certificate.&lt;br/&gt;
- Packet capture confirms the full TLS 1.2 handshake completes correctly at the wire level: ClientHello -&amp;gt; ServerHello -&amp;gt; Certificate -&amp;gt; ServerKeyExchange -&amp;gt; ServerHelloDone -&amp;gt; our ClientKeyExchange/ChangeCipherSpec/Finished -&amp;gt; server's ChangeCipherSpec/Finished. Both sides successfully complete the cryptographic handshake.&lt;br/&gt;
- Despite this, &lt;code&gt;TCP_Client.Upgrade()&lt;/code&gt; subsequently returns &lt;code&gt;NBS.ERROR.CONNECTION_ERROR&lt;/code&gt;, 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.&lt;br/&gt;
- &lt;code&gt;TCP_Client.eErrorID&lt;/code&gt; (inherited from LCon) remains &lt;code&gt;NO_ERROR&lt;/code&gt; throughout - the failure is only visible via &lt;code&gt;Upgrade()&lt;/code&gt;'s return value.&lt;br/&gt;
- Returning &lt;code&gt;0&lt;/code&gt; (&lt;code&gt;ERR_OK&lt;/code&gt;/&lt;code&gt;ERR_CERT_OK&lt;/code&gt; - confirmed via the CmpErrors2 Interfaces documentation that these are literally the same value) makes no difference.&lt;br/&gt;
- Echoing &lt;code&gt;eCurState&lt;/code&gt; 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.&lt;br/&gt;
- Identical result across &lt;code&gt;udiVerificationMode := 0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, and &lt;code&gt;2&lt;/code&gt;.&lt;br/&gt;
- Importing the peer's certificate into the runtime's trusted certificate store via &lt;code&gt;cert-import trusted&lt;/code&gt; makes no difference either.&lt;br/&gt;
- With &lt;code&gt;itfCertVerifer&lt;/code&gt; left at its default (no custom verifier) and &lt;code&gt;udiVerificationMode := 2&lt;/code&gt;, the underlying engine sends its own fatal TLS alert (&lt;code&gt;handshake_failure&lt;/code&gt;) 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Questions:&lt;/strong&gt;&lt;br/&gt;
1. Is there an additional step required for a custom &lt;code&gt;ICertificateVerifier&lt;/code&gt; to actually override an &lt;code&gt;ERR_CERT_HAS_EXPIRED&lt;/code&gt; (or more generally, any &lt;code&gt;ERR_CERT_*&lt;/code&gt;) rejection, or is certificate validation enforced unconditionally for a CLIENT_SIDE &lt;code&gt;TLSContext&lt;/code&gt; regardless of what the verifier returns?&lt;br/&gt;
2. Is &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;ERR_OK&lt;/code&gt; genuinely the correct "accept" return value for &lt;code&gt;VerifyCertificate&lt;/code&gt;, or does it expect something else (a specific bit pattern, a different constant, a value that must be computed from &lt;code&gt;hCert&lt;/code&gt; itself)?&lt;br/&gt;
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?&lt;/p&gt;
&lt;p&gt;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 (&lt;code&gt;rejectUnauthorized: false&lt;/code&gt;) 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 &lt;code&gt;ICertificateVerifier&lt;/code&gt; genuinely can't override this class of rejection.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;Thanks in advance&lt;br/&gt;
/Jørgen&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;&lt;/div&gt;</summary></entry></feed>