Skip to content

Your First Verified TLS Connection in C++

TLS adds protected communication and peer authentication to the TCP connection you already know. Build a C++ client that verifies a local server, sends a short message and reads its encrypted echo.

Before you start

Complete HTTP Connection Reuse. Review TCP deadlines if nonblocking sockets and poll() are unfamiliar. Run the following commands on your Pi, from the examples repository's network/ directory.

Outcome: exchange a verified TLS 1.3 echo and reject an untrusted certificate, a wrong hostname or a peer that exceeds the session deadline.

This is TLS, not HTTPS yet. The application message is the fixed 16-byte string Hello over TLS.\n, not an HTTP request.

What TLS changes

sequenceDiagram accTitle: TCP connects before TLS verifies the server accDescr: The client opens TCP, performs a simplified TLS 1.3 handshake, checks the server identity and only then sends application data. Both sides finish with protected close notifications. participant C as C++ client participant S as Python TLS server C->>S: TCP connect C->>S: ClientHello with key share and SNI S->>C: ServerHello with key share S->>C: Protected handshake and certificate proof Note over C: Verify trusted chain and localhost identity C->>S: Finished C->>S: Protected Hello over TLS S->>C: Protected echo C->>S: close_notify S->>C: close_notify

The figure simplifies a full, certificate-based TLS 1.3 handshake; it does not show every handshake message or TCP packet. There is no resumption, early data or client certificate in this exercise.

TCP still transports bytes. OpenSSL handles the TLS handshake, record protection and certificate processing. Your program decides which authority to trust and which server name to require. We do not implement encryption ourselves.

TLS does not give your application message boundaries. Here, both peers know the one message is 16 bytes long. An arbitrary-length protocol would still need framing.

Install the development dependency

Inspect your installation first:

openssl version
pkg-config --modversion openssl

Having the openssl command does not mean that C++ headers and linker files are installed. On Raspberry Pi OS / Debian, install missing development tools yourself:

sudo apt update
sudo apt install libssl-dev pkg-config

These are administrative changes. The exercise requires OpenSSL 3.0 or later and a working system clock for certificate validity checks. The server fixture also uses Python's ssl module. CMake does not download a TLS implementation.

Build the client:

cmake --preset native-debug
cmake --build --preset native-debug --target tls_echo_client --parallel 2

Or compile it directly:

1
2
3
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/tls-echo/client.cpp -o /tmp/tls_echo_client \
    $(pkg-config --cflags --libs openssl)

Create a disposable local authority

In terminal A:

1
2
3
practice=$(mktemp -d /tmp/network-tls-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$practice/certs"
printf '%s\n' "$practice/certs"

Copy the printed certificate-directory path for terminal B. The generator refuses to overwrite an existing certificate directory. It creates a short-lived practice CA and a separate server certificate with DNS:localhost in its Subject Alternative Name.

The CA certificate is public trust material; the *.key files are secrets. Keep this practice directory private, do not commit it, and never install this CA into the system/browser trust store. These unencrypted practice keys and two-day certificates are not production credentials.

Start a one-connection, loopback-only fixture:

timeout 35s python3 fixtures/tls_echo.py serve 9443 "$practice/certs"

It prints LISTEN 9443, waits for a client, echoes exactly 16 bytes, exchanges TLS close notifications and exits. The outer timeout bounds the whole fixture process.

Run the client

In terminal B, replace the path below with the path printed by terminal A:

certs=/tmp/network-tls-practice.REPLACE/certs
./build/native/debug/tls_echo_client 9443 "$certs/ca.pem"

Expected successful output:

verified=localhost protocol=TLSv1.3
Hello over TLS.

The network destination is always 127.0.0.1. The expected certificate identity defaults to localhost. Connecting to an IP address and verifying a DNS identity are separate decisions.

Read the C++ implementation

Follow the calls in this order:

Step Visible operation Responsibility
Trust policy SSL_CTX_load_verify_locations, SSL_VERIFY_PEER Require a certificate chain rooted in the supplied practice CA.
Server identity SSL_set1_host Check the certificate against the expected DNS name.
Name indication SSL_set_tlsext_host_name Send SNI so a server can select its certificate. SNI is not verification.
TCP socket, connect, poll, getsockopt Establish a nonblocking loopback connection and inspect its result.
TLS SSL_set_fd, SSL_connect Attach TLS to that socket and complete the verified handshake.
Application I/O SSL_write_ex, SSL_read_ex Send plaintext into TLS and receive decrypted bytes from TLS.
Finish SSL_shutdown, frees, close Exchange close notifications, release TLS objects and close the descriptor.

Do not use raw send() or recv() for application data once TLS owns the connection. That would bypass the TLS layer or consume bytes OpenSSL needs.

Full standalone client: app/tls-echo/client.cpp
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    int fd = -1;
    SSL_CTX* context = nullptr;
    SSL* tls = nullptr;
    int status = 0;
    try {
        if (argc < 3 || argc > 5) {
            throw std::invalid_argument(
                "usage: tls_echo_client port ca.pem [hostname [session_ms]]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        const std::string hostname = argc >= 4 ? argv[3] : "localhost";
        if (hostname.empty() || hostname.size() > 253 ||
            hostname.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLM"
                                       "NOPQRSTUVWXYZ0123456789.-") !=
                std::string::npos) {
            throw std::invalid_argument("hostname must be an ASCII DNS name");
        }
        int session_ms = 3000;
        if (argc == 5) {
            const std::string_view budget = argv[4];
            const auto [last, invalid] = std::from_chars(
                budget.data(), budget.data() + budget.size(), session_ms);
            if (invalid != std::errc{} ||
                last != budget.data() + budget.size() || session_ms < 1 ||
                session_ms > 10000) {
                throw std::invalid_argument("session_ms must be 1..10000");
            }
        }
        // OpenSSL writes through the socket BIO, not send(MSG_NOSIGNAL).
        if (std::signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
            throw std::runtime_error("cannot ignore SIGPIPE");
        }
        context = SSL_CTX_new(TLS_client_method());
        if (!context ||
            SSL_CTX_set_min_proto_version(context, TLS1_3_VERSION) != 1 ||
            SSL_CTX_load_verify_locations(context, argv[2], nullptr) != 1) {
            throw std::runtime_error("cannot configure TLS or load CA file");
        }
        SSL_CTX_set_verify(context, SSL_VERIFY_PEER, nullptr);
        tls = SSL_new(context);
        if (!tls || SSL_set1_host(tls, hostname.c_str()) != 1 ||
            SSL_set_tlsext_host_name(tls, hostname.c_str()) != 1) {
            throw std::runtime_error("cannot configure server identity");
        }
        fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        if (SSL_set_fd(tls, fd) != 1) {
            throw std::runtime_error("SSL_set_fd failed");
        }
        const auto deadline = std::chrono::steady_clock::now() +
                              std::chrono::milliseconds(session_ms);
        const auto check_deadline = [&] {
            if (std::chrono::steady_clock::now() >= deadline) {
                throw std::runtime_error("session deadline expired");
            }
        };
        const auto wait_for = [&](short events) {
            for (;;) {
                check_deadline();
                const auto remaining =
                    std::chrono::ceil<std::chrono::milliseconds>(
                        deadline - std::chrono::steady_clock::now())
                        .count();
                if (remaining <= 0) {
                    throw std::runtime_error("session deadline expired");
                }
                pollfd ready{fd, events, 0};
                const int result =
                    ::poll(&ready, 1, static_cast<int>(remaining));
                if (result < 0 && errno == EINTR) {
                    continue;
                }
                if (result < 0) {
                    throw std::system_error(errno, std::generic_category(),
                                            "poll");
                }
                check_deadline();
                if (result > 0) {
                    return; // Retry the socket/TLS operation to inspect errors.
                }
            }
        };
        const auto retry_tls = [&](int result) {
            // No intervening OpenSSL call: result belongs to the last TLS call.
            const int error_code = SSL_get_error(tls, result);
            if (error_code == SSL_ERROR_WANT_READ) {
                wait_for(POLLIN);
            } else if (error_code == SSL_ERROR_WANT_WRITE) {
                wait_for(POLLOUT);
            } else {
                char detail[256]{};
                ERR_error_string_n(ERR_get_error(), detail, sizeof(detail));
                throw std::runtime_error("TLS operation failed (SSL error " +
                                         std::to_string(error_code) +
                                         "): " + detail);
            }
        };
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                      sizeof(server)) < 0) {
            if (errno != EINPROGRESS) {
                throw std::system_error(errno, std::generic_category(),
                                        "connect");
            }
            wait_for(POLLOUT);
            int socket_error = 0;
            socklen_t length = sizeof(socket_error);
            if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &length) <
                0) {
                throw std::system_error(errno, std::generic_category(),
                                        "getsockopt");
            }
            if (socket_error != 0) {
                throw std::system_error(socket_error, std::generic_category(),
                                        "connect");
            }
        }
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_connect(tls);
            if (result == 1) {
                break;
            }
            retry_tls(result);
        }
        if (SSL_get_verify_result(tls) != X509_V_OK) {
            throw std::runtime_error("server certificate verification failed");
        }
        constexpr std::string_view message = "Hello over TLS.\n";
        std::size_t sent = 0;
        while (sent < message.size()) {
            check_deadline();
            std::size_t written = 0;
            ERR_clear_error();
            const int result = SSL_write_ex(tls, message.data() + sent,
                                            message.size() - sent, &written);
            if (result == 1) {
                sent += written;
            } else {
                retry_tls(result);
            }
        }
        std::array<char, message.size()> reply{};
        std::size_t received = 0;
        while (received < reply.size()) {
            check_deadline();
            std::size_t count = 0;
            ERR_clear_error();
            const int result = SSL_read_ex(tls, reply.data() + received,
                                           reply.size() - received, &count);
            if (result == 1) {
                received += count;
            } else {
                retry_tls(result);
            }
        }
        if (std::string_view(reply.data(), reply.size()) != message) {
            throw std::runtime_error("echo mismatch");
        }
        // Require both close_notify alerts within the same session budget.
        for (;;) {
            check_deadline();
            ERR_clear_error();
            const int result = SSL_shutdown(tls);
            if (result == 1) {
                break;
            }
            if (result < 0) {
                retry_tls(result);
            }
        }
        std::cout << "verified=" << hostname
                  << " protocol=" << SSL_get_version(tls) << '\n'
                  << message;
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    SSL_free(tls);
    SSL_CTX_free(context);
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Retry the TLS operation, not just the socket

A nonblocking TLS call can need either read or write readiness. Check SSL_get_error() immediately after the failed call, with no intervening OpenSSL call. Wait for its requested direction, then retry the same operation.

ERR_clear_error() clears the thread's error queue before each operation. A write retry uses the same unsent buffer and length; it does not advance the offset until OpenSSL reports success. A socket-ready event alone does not mean the handshake or application operation succeeded.

The lambdas keep this retry/deadline logic local; there is no course-specific socket header or wrapper class.

One deadline, including shutdown

The default 3000 ms budget starts just before TCP connect and covers connect, handshake, application I/O and TLS shutdown. Local argument parsing, CA-file loading and TLS-object setup happen before that budget. Progress does not extend it.

SSL_shutdown() can return zero after sending our close notification, before receiving the peer's. Continue the shutdown state machine; do not confuse zero here with successful two-way completion. An unexpected TCP EOF is not a verified TLS close.

The process ignores SIGPIPE because OpenSSL's socket BIO writes do not use our earlier MSG_NOSIGNAL flag. Failures instead reach the error path and explicit cleanup. This is a small, single-connection program, not a process-wide signal policy for a larger application.

Check failure paths

Run the automated cases without manually starting the fixture:

ctest --preset native-debug -R network.tls_echo --output-on-failure

The checks cover successful/split echoes, wrong CA and hostname, missing/malformed trust files, a TLS 1.2-only peer, plaintext on the TLS port, stalled handshake/read/shutdown, slow progress, short or wrong echoes, abrupt TCP closure, invalid input and connection refusal.

The TLS 1.2 rejection is this exercise's TLS 1.3-only policy, not a claim that every TLS 1.2 deployment is invalid. These checks exercise API behavior; they are not a cryptographic audit.

Troubleshooting

  • OpenSSL not found / ssl.h missing: install the development package, not just the command-line program. Reconfigure CMake afterward.
  • Cannot load CA file: check the printed practice path and that ca.pem exists.
  • Certificate verification failure: check hostname, CA file and system time. Regenerate expired two-day practice certificates in a fresh directory; never turn verification off.
  • Connection refused: restart terminal A's one-connection fixture before each manual attempt.
  • Session deadline expired: distinguish a stuck handshake, stalled echo and absent close notification. Do not solve an untrusted certificate by increasing the budget.
  • Port already in use: choose an unused loopback port in both commands; do not terminate an unrelated process.

Check your understanding

  1. Why are both CA trust and hostname verification needed?
  2. Why can SSL_write_ex() need read readiness?
  3. Does one successful TLS read correspond to one application message?
  4. Why is closing the TCP descriptor not the same as a clean TLS shutdown?
Answers

Trust checks the issuer chain; hostname checking ties the certificate to the intended server. TLS protocol work can require input during a write. Reads expose a byte stream, so the application still needs framing. A clean TLS shutdown exchanges close notifications; a raw TCP close can truncate protected communication.

Continue the course

Previous: HTTP Connection Reuse · Next: Certificates, Hostnames and Trust

References: TLS 1.3 handshake, OpenSSL identity checking, TLS error handling, TLS shutdown.

Donate