ICertificateVerifier.VerifyCertificate doesn't appear to override an ERR_CERT_HAS_EXPIRED rejection in TCP_Client.Upgrade()

baltzer
2026-07-26
2026-08-06
  • baltzer - 2026-07-26

    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.

    Implementation:

    FUNCTION_BLOCK FB_MyCertVerifier IMPLEMENTS NBS.ICertificateVerifier
    VAR
        udiCallCount  : UDINT;
        aeCurStateLog : ARRAY[0..9] OF NBS.RTS_IEC_RESULT;
    END_VAR
    
    METHOD VerifyCertificate : UDINT
    VAR_INPUT
        hCert     : NBS.RTS_IEC_HANDLE;
        eCurState : NBS.RTS_IEC_RESULT;
    END_VAR
    
    IF udiCallCount < 10 THEN
        aeCurStateLog[udiCallCount] := eCurState;
    END_IF
    udiCallCount := udiCallCount + 1;
    
    VerifyCertificate := 0; // also tried: VerifyCertificate := eCurState;
    

    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.

     
  • baltzer - 2026-08-06

    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:

    CODESYS  --plain TCP-->  127.0.0.1:<port>  --stunnel/TLS-->  <remote device>:<port>
    

    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.

     

Log in to post a comment.