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.
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.