<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Recent posts to ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</title><link>https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/</link><description>Recent posts to ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</description><language>en</language><lastBuildDate>Sun, 26 Jul 2026 09:53:51 -0000</lastBuildDate><atom:link href="https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/feed.rss" rel="self" type="application/rss+xml"></atom:link><item><title>ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()</title><link>https://forge.codesys.com/forge/talk/Engineering/thread/49500df473/?limit=25#b54d</link><description>&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;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">baltzer</dc:creator><pubDate>Sun, 26 Jul 2026 09:53:51 -0000</pubDate><guid isPermaLink="false">https://forge.codesys.com48a0a3ea1b956f833eba9afe7cc5e714e420ce35</guid></item></channel></rss>