Secure Communication (TLS)

This example shows how to establish an encrypted and authenticated TLS connection to the IM7 potentiostat using the zahner_link library.

When the USE_SSL connection flag is set, all communication with the IM7 is encrypted. Encryption alone, however, does not protect against a man-in-the-middle (MITM) attack - you also have to authenticate the device by verifying its TLS certificate. This notebook shows the different ways to establish that trust.

TLS behaviour is controlled through two things:

This notebook covers:

  • Encrypted but unauthenticated: connecting with certificate validation disabled (insecure - debugging only).

  • Inspecting the certificate chain: reading the server certificate via ssl_get_last_verification_result(), even when validation fails.

  • Trust on first use (fingerprint pinning): trusting the exact device certificate by its SHA-256 fingerprint.

  • Custom CA from a file: trusting a self-signed / internal CA that signed the device certificate.

  • Custom hostname verification: connecting via an IP while validating the certificate against its DNS/mDNS name.

  • In-memory CA: supplying the CA certificate as a PEM string instead of a file path.

Unlike the other examples we use the return-value based ZahnerLink class here (instead of ZahnerLinkExc): with TLS we want to inspect the connection result and the presented certificate even when the connection is refused.

⚠️ Security note: The flags SKIP_SSL_CERT_VALIDATION and SKIP_SSL_HOSTNAME_MATCHING disable security protections. They are shown for completeness and debugging - do not use them in production.

import zahner_link as zl

# --- Connection target -------------------------------------------------------
HOST = "10.10.253.150"
PORT = "1994"  # the IM7 serves both plain and TLS traffic on the same port

# The name the *device* certificate is issued to. For the IM7 this is its mDNS
# name, derived from the serial number (CommonName / SAN, e.g. "im7-72000044.local").
# We may connect via an IP but validate the certificate against this name - see
# the "custom hostname" scenario below.
# NOTE: adjust this to match your device (its serial number).
CERT_HOSTNAME = "im7-72000044.local"

# CA certificate (PEM) that signed the device certificate, placed next to this
# notebook. Only needed for the custom-CA scenarios further down.
CA_FILE = "zahner-im7-root-ca.crt"


def print_verification_result(link: zl.ZahnerLink):
    """Print the outcome of the last TLS verification and the certificate chain
    the server presented (a list of TlsCertificateInfo)."""
    result = link.ssl_get_last_verification_result()
    print(f"  trusted_by_system: {result.trusted_by_system}")
    print(f"  openssl_error:     {result.openssl_error_code}")
    print(f"  error_message:     {result.error_message!r}")
    print(f"  certificate chain ({len(result.chain)} certificate(s)):")
    for i, cert in enumerate(result.chain):
        role = "leaf / device" if i == 0 else "issuer"
        print(f"    [{i}] ({role}) common_name={cert.common_name!r}")
        print(f"        issuer      = {cert.issuer_dn!r}")
        print(f"        valid_until = {cert.valid_until!r} (expired: {cert.is_expired})")
        print(f"        SHA-256     = {cert.sha256_fingerprint}")


def try_connect(link: zl.ZahnerLink) -> zl.ErrorObject:
    """Attempt to connect and report the result without raising."""
    error = link.connect()
    if not error:
        print(f"✅ connected securely (code: {error.get_error_code_enum()})")
    else:
        print(f"❌ connection refused: {error.get_error_code_enum()} - {error.get_message_formatted()}")
    return error

1. Encrypted but Unauthenticated (insecure)

Setting USE_SSL together with SKIP_SSL_CERT_VALIDATION encrypts the traffic but accepts any certificate. The connection always succeeds - even against an attacker impersonating the device. This protects against passive eavesdropping only, not against a man-in-the-middle. Use it for a quick test, never in production.

We confirm that the transport is actually encrypted by reading using_tls from our own connection info.

link = zl.ZahnerLink(
    HOST,
    PORT,
    zl.ZahnerLinkConnectionFlags.USE_SSL | zl.ZahnerLinkConnectionFlags.SKIP_SSL_CERT_VALIDATION,
)

error = try_connect(link)

if not error:
    info = link.get_own_connection_info()
    print(f"using_tls: {info.using_tls}")
    print("\nThe certificate was accepted without any checks:")
    print_verification_result(link)

link.disconnect()
✅ connected securely (code: ErrorCodeEnum.NONE)
using_tls: True

The certificate was accepted without any checks:
  trusted_by_system: True
  openssl_error:     0
  error_message:     ''
  certificate chain (0 certificate(s)):

2. Strict Validation and Inspecting the Certificate Chain

With only USE_SSL set and no trust configured, the device’s internally-signed certificate cannot be verified against a trusted root, so the client aborts the TLS handshake: connect() returns ErrorCodeEnum.TLS_HANDSHAKE_FAILED. That is the secure default - an untrusted certificate is rejected.

The connection error code only tells you that the handshake failed. The reason is in the TlsVerificationResult returned by ssl_get_last_verification_result(): here trusted_by_system is False and openssl_error is 20 («unable to get local issuer certificate») - the internal CA is unknown to the system. Importantly, the library still captured the certificate chain the server presented (a list of TlsCertificateInfo), which lets a user inspect the certificate and decide whether to trust it (next scenario).

link = zl.ZahnerLink(HOST, PORT, zl.ZahnerLinkConnectionFlags.USE_SSL)

error = try_connect(link)

# The handshake is aborted because the certificate is not trusted yet. The
# connection error code only says the handshake failed - the actual reason is
# in the verification result.
result = link.ssl_get_last_verification_result()
print(f"connection error:     {error.get_error_code_enum()}")
print(f"trusted by system:    {result.trusted_by_system}")
print(f"openssl verify error: {result.openssl_error_code} ({result.error_message})")

print("\nCertificate chain presented by the device:")
print_verification_result(link)

link.disconnect()
❌ connection refused: ErrorCodeEnum.TLS_HANDSHAKE_FAILED - TLS handshake failed
connection error:     ErrorCodeEnum.TLS_HANDSHAKE_FAILED
trusted by system:    False
openssl verify error: 20 (unable to get local issuer certificate)

Certificate chain presented by the device:
  trusted_by_system: False
  openssl_error:     20
  error_message:     'unable to get local issuer certificate'
  certificate chain (2 certificate(s)):
    [0] (leaf / device) common_name='im7-72000044.local'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2032, 7, 25, 8, 17, 55) (expired: False)
        SHA-256     = 88:2E:12:21:43:F9:B2:64:99:44:1C:FA:19:68:C2:15:21:42:59:64:17:6D:A2:84:16:5B:FC:1F:6E:4B:F9:D9
    [1] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2036, 6, 22, 12, 16, 49) (expired: False)
        SHA-256     = 54:CA:33:9C:57:D3:C3:79:19:42:E6:E8:E7:8E:95:D5:E7:76:1E:52:6D:DA:BE:A5:25:C7:9A:CF:3B:80:50:68

3. Trust on First Use - Pinning the Certificate Fingerprint

If you have no CA infrastructure, the simplest way to authenticate the device is certificate pinning: take the SHA-256 fingerprint of the certificate the device presents, verify it out-of-band once (e.g. compare it with the fingerprint shown in the device’s web interface), and from then on trust exactly that certificate.

A certificate whose fingerprint is listed in TlsConfiguration.trusted_cert_fingerprints is trusted unconditionally - chain and hostname checks are bypassed. If the device later presents a different certificate the handshake fails again, which may indicate a MITM attack (or simply a renewed certificate).

Below we connect once (which is refused with TLS_HANDSHAKE_FAILED, exactly as in scenario 2), read the device certificate’s fingerprint from the captured chain, pin it via ssl_set_tls_configuration(), and reconnect on the same object - this time successfully.

link = zl.ZahnerLink(HOST, PORT, zl.ZahnerLinkConnectionFlags.USE_SSL)

# The first attempt is *expected* to be refused (we do not trust the device
# yet), but it still hands us the certificate chain.
print("initial attempt (expected to be refused):")
try_connect(link)

trusted_fingerprint = ""
chain = link.ssl_get_last_verification_result().chain
if chain:
    # The first certificate in the chain is the device's own (leaf) certificate.
    trusted_fingerprint = chain[0].sha256_fingerprint
print(f"\nfingerprint to pin: {trusted_fingerprint}")
# ⚠️ In a real deployment, verify this fingerprint out-of-band before trusting it!

tls_config = zl.TlsConfiguration(trusted_cert_fingerprints=[trusted_fingerprint])
link.ssl_set_tls_configuration(tls_config)

print("\nreconnecting with the pinned fingerprint:")
try_connect(link)

link.disconnect()
initial attempt (expected to be refused):
❌ connection refused: ErrorCodeEnum.TLS_HANDSHAKE_FAILED - TLS handshake failed

fingerprint to pin: 88:2E:12:21:43:F9:B2:64:99:44:1C:FA:19:68:C2:15:21:42:59:64:17:6D:A2:84:16:5B:FC:1F:6E:4B:F9:D9

reconnecting with the pinned fingerprint:
✅ connected securely (code: ErrorCodeEnum.NONE)

4. Trusting a Custom CA Certificate (from a file)

If your devices use certificates signed by your own certificate authority (CA), you can trust that CA directly. Every device certificate signed by it is then accepted, without pinning each one individually. We put the CA certificate path into TlsConfiguration.trusted_ca_files and combine it with two flags:

  • SKIP_SSL_HOSTNAME_MATCHING - accept the certificate even if the host we connect to does not match the certificate name (e.g. when connecting by IP).

  • SSL_DO_NOT_USE_SYSTEM_CAS - important: once hostname matching is disabled, do not also trust the operating system’s root CAs. Otherwise any publicly trusted certificate (e.g. a random Let’s Encrypt certificate) would be accepted and a MITM becomes possible again.

The next scenario shows a stronger alternative that keeps hostname verification.

This cell needs the real zahner-im7-root-ca.crt next to this notebook.

link = zl.ZahnerLink(HOST, PORT, zl.ZahnerLinkConnectionFlags.USE_SSL)

tls_config = zl.TlsConfiguration(trusted_ca_files=[CA_FILE])
link.ssl_set_tls_configuration(tls_config)

# Accept the certificate purely based on the CA signature, ignoring the hostname...
link.set_flag(zl.ZahnerLinkConnectionFlags.SKIP_SSL_HOSTNAME_MATCHING)
# ...and therefore stop trusting the system root CAs (see the note above).
link.set_flag(zl.ZahnerLinkConnectionFlags.SSL_DO_NOT_USE_SYSTEM_CAS)

try_connect(link)
print_verification_result(link)

link.disconnect()
✅ connected securely (code: ErrorCodeEnum.NONE)
  trusted_by_system: True
  openssl_error:     0
  error_message:     ''
  certificate chain (3 certificate(s)):
    [0] (leaf / device) common_name='im7-72000044.local'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2032, 7, 25, 8, 17, 55) (expired: False)
        SHA-256     = 88:2E:12:21:43:F9:B2:64:99:44:1C:FA:19:68:C2:15:21:42:59:64:17:6D:A2:84:16:5B:FC:1F:6E:4B:F9:D9
    [1] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2036, 6, 22, 12, 16, 49) (expired: False)
        SHA-256     = 54:CA:33:9C:57:D3:C3:79:19:42:E6:E8:E7:8E:95:D5:E7:76:1E:52:6D:DA:BE:A5:25:C7:9A:CF:3B:80:50:68
    [2] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Root CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2046, 6, 20, 12, 15, 44) (expired: False)
        SHA-256     = B8:4D:8F:BA:3F:54:E6:C0:C8:17:2E:D3:38:97:AB:FD:69:CF:E6:0F:56:20:CB:98:BA:6D:C7:68:EF:03:D2:81

5. Custom Hostname Verification

Skipping the hostname check (previous scenario) is convenient but weaker - it no longer verifies which device you are talking to. A better option, when you connect via an IP (or a custom DNS name) but the certificate is issued to a different name (typically the device’s mDNS name such as im7-72000044.local), is SSL_VERIFY_CUSTOM_HOSTNAME.

With this flag set, the certificate is validated against TlsConfiguration.certificate_hostname instead of the host you connect to. DNS resolution, the TCP connection and the HTTP Host header keep using HOST, while both the SNI name and the certificate hostname check use certificate_hostname. This keeps full hostname verification while still connecting by IP.

link = zl.ZahnerLink(
    HOST,
    PORT,
    zl.ZahnerLinkConnectionFlags.USE_SSL
    | zl.ZahnerLinkConnectionFlags.SSL_VERIFY_CUSTOM_HOSTNAME
    | zl.ZahnerLinkConnectionFlags.SSL_DO_NOT_USE_SYSTEM_CAS,
)

tls_config = zl.TlsConfiguration(
    trusted_ca_files=[CA_FILE],
    certificate_hostname=CERT_HOSTNAME,
)
link.ssl_set_tls_configuration(tls_config)

print(f"connecting to {HOST} but validating the certificate against {CERT_HOSTNAME!r}:")
try_connect(link)
print_verification_result(link)

link.disconnect()
connecting to 10.10.253.150 but validating the certificate against 'im7-72000044.local':
✅ connected securely (code: ErrorCodeEnum.NONE)
  trusted_by_system: True
  openssl_error:     0
  error_message:     ''
  certificate chain (3 certificate(s)):
    [0] (leaf / device) common_name='im7-72000044.local'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2032, 7, 25, 8, 17, 55) (expired: False)
        SHA-256     = 88:2E:12:21:43:F9:B2:64:99:44:1C:FA:19:68:C2:15:21:42:59:64:17:6D:A2:84:16:5B:FC:1F:6E:4B:F9:D9
    [1] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2036, 6, 22, 12, 16, 49) (expired: False)
        SHA-256     = 54:CA:33:9C:57:D3:C3:79:19:42:E6:E8:E7:8E:95:D5:E7:76:1E:52:6D:DA:BE:A5:25:C7:9A:CF:3B:80:50:68
    [2] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Root CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2046, 6, 20, 12, 15, 44) (expired: False)
        SHA-256     = B8:4D:8F:BA:3F:54:E6:C0:C8:17:2E:D3:38:97:AB:FD:69:CF:E6:0F:56:20:CB:98:BA:6D:C7:68:EF:03:D2:81

6. Supplying the CA In-Memory (PEM string)

Instead of a file path you can hand the CA certificate to the library directly as an in-memory PEM string via TlsConfiguration.trusted_ca_certificates. This is useful when the CA is embedded in your application (as a resource or a configuration value) rather than stored on disk. Each entry may even contain a bundle of several concatenated PEM certificates.

Here we simply read the PEM text from zahner-im7-root-ca.crt to obtain the string; in a real application it could come from anywhere.

with open(CA_FILE, "r", encoding="ascii") as f:
    ca_pem = f.read()

link = zl.ZahnerLink(
    HOST,
    PORT,
    zl.ZahnerLinkConnectionFlags.USE_SSL
    | zl.ZahnerLinkConnectionFlags.SSL_VERIFY_CUSTOM_HOSTNAME
    | zl.ZahnerLinkConnectionFlags.SSL_DO_NOT_USE_SYSTEM_CAS,
)

tls_config = zl.TlsConfiguration(
    trusted_ca_certificates=[ca_pem],
    certificate_hostname=CERT_HOSTNAME,
)
link.ssl_set_tls_configuration(tls_config)

print(f"connecting using an in-memory CA ({len(ca_pem)} bytes):")
try_connect(link)
print_verification_result(link)

link.disconnect()
connecting using an in-memory CA (875 bytes):
✅ connected securely (code: ErrorCodeEnum.NONE)
  trusted_by_system: True
  openssl_error:     0
  error_message:     ''
  certificate chain (3 certificate(s)):
    [0] (leaf / device) common_name='im7-72000044.local'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2032, 7, 25, 8, 17, 55) (expired: False)
        SHA-256     = 88:2E:12:21:43:F9:B2:64:99:44:1C:FA:19:68:C2:15:21:42:59:64:17:6D:A2:84:16:5B:FC:1F:6E:4B:F9:D9
    [1] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Factory Deployment Sub-CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2036, 6, 22, 12, 16, 49) (expired: False)
        SHA-256     = 54:CA:33:9C:57:D3:C3:79:19:42:E6:E8:E7:8E:95:D5:E7:76:1E:52:6D:DA:BE:A5:25:C7:9A:CF:3B:80:50:68
    [2] (issuer) common_name='Zahner-Elektrik GmbH & Co. KG IM7 Root CA'
        issuer      = 'CN=Zahner-Elektrik GmbH & Co. KG IM7 Root CA,O=Zahner-Elektrik GmbH & Co. KG'
        valid_until = datetime.datetime(2046, 6, 20, 12, 15, 44) (expired: False)
        SHA-256     = B8:4D:8F:BA:3F:54:E6:C0:C8:17:2E:D3:38:97:AB:FD:69:CF:E6:0F:56:20:CB:98:BA:6D:C7:68:EF:03:D2:81

Summary: Which Approach Should I Use?

Approach

Flags / configuration

Security

When to use

No encryption (plain)

NONE (no USE_SSL)

❌ plaintext - eavesdropping and MITM possible

isolated / trusted network only

Skip validation

USE_SSL + SKIP_SSL_CERT_VALIDATION

❌ encrypted only, MITM possible

quick debugging only

Fingerprint pinning

USE_SSL + trusted_cert_fingerprints

✅ strong, per device

no CA infrastructure, few devices

Custom CA + skip hostname

USE_SSL + trusted_ca_files + SKIP_SSL_HOSTNAME_MATCHING + SSL_DO_NOT_USE_SYSTEM_CAS

⚠️ authenticates the CA, not the exact host

internal CA, connecting by IP

Custom hostname

USE_SSL + trusted_ca_files + SSL_VERIFY_CUSTOM_HOSTNAME + certificate_hostname

✅ full validation via IP

internal CA, certificate issued to an mDNS/DNS name

Rule of thumb: prefer full certificate validation - fingerprint pinning, or a custom CA together with hostname verification. Whenever you disable hostname matching, also set SSL_DO_NOT_USE_SYSTEM_CAS so that only your own CA is trusted.