Skip to content

WebSocket over Verified TLS in C++

WSS carries WebSocket traffic through TLS. Keep the previous message parser, but authenticate the server before sending HTTP Upgrade and use TLS I/O for every subsequent application byte.

Before you start

Complete Fragmented Messages, Verified TLS and Certificates, Hostnames and Trust. Run commands on the Pi from the examples repository's network/ directory, using its C++20/OpenSSL development setup and Python fixtures.

Outcome: connect to a local wss://localhost:9445/ws fixture, verify its certificate for localhost, exchange fragmented text and Ping/Pong, and finish both WebSocket and TLS closure.

The socket still connects only to IPv4 loopback. This teaching client fixes the DNS identity to localhost, requires TLS 1.3 and explicit http/1.1 ALPN selection, and implements the same reduced ASCII/Close-1000 profile. Those TLS-version and ALPN requirements are sample policies, not universal WSS requirements.

Four checks, four different jobs

Stage What this example verifies What it does not prove
TLS authentication The server's certificate chains to the supplied CA and matches localhost. That this application or its content is trustworthy.
ALPN The peer explicitly selected http/1.1, the protocol this Upgrade parser understands. That HTTP Upgrade will be accepted.
HTTP Upgrade Status 101, negotiation fields and this request's Sec-WebSocket-Accept. The server's identity; that belongs to TLS verification.
WebSocket messages Frame rules, the exact assembled echo and matching Ping token. The health of another sensor, service or future session.

SNI selects the intended server name; SSL_set1_host() configures the separate identity check. Enabling encryption without peer verification is not enough. The opening handshake's SHA-1 accept calculation is a protocol field, not a replacement for certificate verification.

The client sends neither HTTP Upgrade nor WebSocket frames if TLS identity or the selected application protocol fails. There is no certificate bypass, plaintext retry or insecure fallback.

Follow the protected exchange and both closures

sequenceDiagram accTitle: WSS authenticates first and closes two protocol layers accDescr: The client connects over TCP and negotiates TLS with localhost SNI and HTTP/1.1 ALPN. It validates the certificate before sending Upgrade. The encrypted application exchange includes fragmented text, Ping and Pong, then WebSocket Close in both directions. The fixture sends TLS close_notify, the client responds, and the socket is closed. participant C as C++ client participant S as Local TLS fixture C->>S: TCP connect to loopback C->>S: TLS handshake, SNI localhost, ALPN offer S->>C: Certificate and TLS handshake response Note over C: Verify CA, localhost and selected HTTP/1.1 Note over C,S: Following application messages travel inside TLS C->>S: GET /ws with WebSocket Upgrade S->>C: HTTP 101 with matching accept Note over C: Validate Upgrade before sending frames C->>S: Token Ping and three masked data frames S->>C: First nonfinal text fragment and Ping C->>S: Masked Pong with identical Ping bytes S->>C: Matching Pong and remaining continuations C->>S: Masked WebSocket Close 1000 S->>C: WebSocket Close 1000 S->>C: TLS close_notify C->>S: TLS close_notify Note over C: Free TLS state, close socket

The arrows show application-level meaning, not TCP packets or TLS record boundaries. Client frames still need their masks inside TLS. Masking and encryption have different roles; adding TLS does not remove WebSocket framing rules.

Create temporary certificates and run WSS

Build:

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

In terminal A, create a fresh private practice directory and print its certificate path:

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

After LISTEN 9445, use terminal B. Replace the placeholder with the complete certificate-directory path printed above; terminal A's shell variable does not exist in terminal B:

wss_certs="/paste/the/printed/certificate-directory"
./build/native/debug/wss_client 9445 "$wss_certs/ca.pem"

Expected output:

1
2
3
4
5
verified=localhost protocol=TLSv1.3 alpn=http/1.1
echo_bytes=256
sent_frames=3 received_frames=3
matched_pong=yes pings_replied=1 pongs_ignored=0
close=1000 tls_close_notify=yes

The fixture echoes three data frames with different boundaries from the client's three frames, and requires a Pong before releasing the final continuation. Frame counts exclude controls. Restart the one-connection fixture before every attempt.

The optional payload size accepts 0–65536 bytes (default 256); an optional final session budget accepts 1–10000 ms (default 3000):

# Against a freshly started fixture.
./build/native/debug/wss_client 9445 "$wss_certs/ca.pem" 65536 3000

The generated certificates expire after two days. Generate fresh ones in a new practice directory when needed. The CA is explicitly trusted only by this command: do not install it into system/browser trust or commit private keys. The fixture authenticates the server, not the client; it does not implement mutual TLS or application login.

Change the transport, not the message rules

The standalone file retains raw socket(), connect(), poll() and explicit close(). After connecting, OpenSSL manages the TLS protocol and cryptography through the socket BIO.

After SSL_connect(), all Upgrade/frame writes use SSL_write_ex() and all application reads use SSL_read_ex(). Do not mix raw send() or recv() with this encrypted stream. A direct socket read would see TLS records, not decoded WebSocket bytes.

The small local send_bytes and receive_bytes lambdas expose those TLS calls in this file. They do not hide sockets in a project-specific header.

For a nonblocking operation, inspect its immediate SSL_get_error() result and wait for the requested direction. A read may need write readiness, and a write may need read readiness. On a write retry, keep the same pointer, length and data until the operation succeeds. Call the TLS read first rather than always polling the raw socket: OpenSSL may already hold decrypted bytes. See TLS reads and TLS writes.

The existing pending buffer and continuation state still handle partial/coalesced application reads, separate controls from data, and compare one complete ASCII echo. TLS records do not replace WebSocket lengths, FIN or message assembly.

Read the standalone C++ implementation

Focus on TLS setup, the two transport lambdas, the certificate/ALPN gate before request, and the closing code. The bounded frame decoder and message loop repeat the preceding lesson without a shared socket wrapper.

Full standalone client: app/wss/client.cpp
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <iostream>
#include <map>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/ssl.h>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <system_error>
#include <tuple>
#include <unistd.h>
#include <utility>

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: wss_client port ca.pem [payload_bytes [session_ms]]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, invalid] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (invalid != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        constexpr std::uint64_t payload_limit = 65536;
        unsigned int payload_bytes = 256;
        if (argc >= 4) {
            const std::string_view count = argv[3];
            const auto [last, error] = std::from_chars(
                count.data(), count.data() + count.size(), payload_bytes);
            if (error != std::errc{} || last != count.data() + count.size() ||
                payload_bytes > payload_limit) {
                throw std::invalid_argument("payload_bytes must be 0..65536");
            }
        }
        // Predictable printable text, without a huge command-line argument.
        std::string message(payload_bytes, 'x');
        for (std::size_t i = 0; i < message.size(); ++i) {
            message[i] = static_cast<char>('a' + i % 26);
        }
        int session_ms = 3000;
        if (argc == 5) {
            const std::string_view budget = argv[4];
            const auto [last, error] = std::from_chars(
                budget.data(), budget.data() + budget.size(), session_ms);
            if (error != std::errc{} || last != budget.data() + budget.size() ||
                session_ms < 1 || session_ms > 10000) {
                throw std::invalid_argument("session_ms must be 1..10000");
            }
        }
        constexpr std::string_view hostname = "localhost";
        // 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.data()) != 1 ||
            SSL_set_tlsext_host_name(tls, hostname.data()) != 1) {
            throw std::runtime_error("cannot configure server identity");
        }
        // ALPN uses length-prefixed protocol names, not a C string.
        constexpr unsigned char protocols[] = {8,   'h', 't', 't', 'p',
                                               '/', '1', '.', '1'};
        if (SSL_set_alpn_protos(tls, protocols, sizeof(protocols)) != 0) {
            throw std::runtime_error("cannot offer HTTP/1.1 through ALPN");
        }
        std::array<unsigned char, 16> nonce{};
        std::array<unsigned char, 25> encoded_key{};
        if (RAND_bytes(nonce.data(), nonce.size()) != 1 ||
            EVP_EncodeBlock(encoded_key.data(), nonce.data(), nonce.size()) !=
                24) {
            throw std::runtime_error("cannot generate WebSocket key");
        }
        const std::string key(reinterpret_cast<const char*>(encoded_key.data()),
                              24);
        const std::string challenge =
            key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
        std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
        std::size_t digest_size = 0;
        std::array<unsigned char, 29> encoded_accept{};
        if (EVP_Q_digest(nullptr, "SHA1", nullptr, challenge.data(),
                         challenge.size(), digest.data(), &digest_size) != 1 ||
            digest_size != 20 ||
            EVP_EncodeBlock(encoded_accept.data(), digest.data(), 20) != 28) {
            throw std::runtime_error("cannot compute WebSocket accept value");
        }
        const std::string expected_accept(
            reinterpret_cast<const char*>(encoded_accept.data()), 28);
        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; // The next socket operation inspects EOF/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);
            }
        };
        const auto send_bytes = [&](std::string_view bytes) {
            std::size_t offset = 0;
            while (offset < bytes.size()) {
                check_deadline();
                std::size_t written = 0;
                ERR_clear_error();
                const int result =
                    SSL_write_ex(tls, bytes.data() + offset,
                                 bytes.size() - offset, &written);
                if (result == 1) {
                    offset += written;
                } else {
                    // Retry the same pointer and length after WANT_READ/WRITE.
                    retry_tls(result);
                }
            }
        };
        const auto receive_bytes = [&](char* destination,
                                       std::size_t capacity) {
            for (;;) {
                check_deadline();
                std::size_t received = 0;
                ERR_clear_error();
                const int result =
                    SSL_read_ex(tls, destination, capacity, &received);
                if (result == 1) {
                    return received;
                }
                retry_tls(result); // An early TLS EOF is not a complete frame.
            }
        };
        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 size = sizeof(socket_error);
            if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &size) <
                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");
        }
        const unsigned char* selected = nullptr;
        unsigned int selected_length = 0;
        SSL_get0_alpn_selected(tls, &selected, &selected_length);
        if (selected_length != 8 ||
            std::string_view(reinterpret_cast<const char*>(selected),
                             selected_length) != "http/1.1") {
            throw std::runtime_error("server did not select HTTP/1.1");
        }
        const std::string request =
            "GET /ws HTTP/1.1\r\nHost: localhost:" + std::to_string(port) +
            "\r\nUpgrade: websocket\r\nConnection: "
            "Upgrade\r\nSec-WebSocket-Key: " +
            key + "\r\nSec-WebSocket-Version: 13\r\n\r\n";
        send_bytes(request);
        // Never send raw socket bytes after SSL_connect, including frames.
        std::string response;
        std::array<char, 64> buffer{};
        std::size_t boundary = std::string::npos;
        while (boundary == std::string::npos) {
            check_deadline();
            const auto count = receive_bytes(buffer.data(), buffer.size());
            response.append(buffer.data(), count);
            boundary = response.find("\r\n\r\n");
            if ((boundary == std::string::npos && response.size() > 4096) ||
                (boundary != std::string::npos && boundary + 4 > 4096)) {
                throw std::runtime_error("handshake headers exceed 4096 bytes");
            }
        }
        const auto trim = [](std::string_view value) {
            while (!value.empty() &&
                   (value.front() == ' ' || value.front() == '\t')) {
                value.remove_prefix(1);
            }
            while (!value.empty() &&
                   (value.back() == ' ' || value.back() == '\t')) {
                value.remove_suffix(1);
            }
            return value;
        };
        const auto lower = [](std::string value) {
            for (char& c : value) {
                if (c >= 'A' && c <= 'Z') {
                    c += 'a' - 'A';
                }
            }
            return value;
        };
        const auto token_char = [](unsigned char c) {
            return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
                   (c >= '0' && c <= '9') ||
                   std::string_view("!#$%&'*+-.^_`|~").find(c) !=
                       std::string_view::npos;
        };
        const auto first_end = response.find("\r\n");
        const std::string_view status_line(response.data(), first_end);
        if (status_line.size() < 13 ||
            !status_line.starts_with("HTTP/1.1 101 ") ||
            !std::all_of(status_line.begin(), status_line.end(),
                         [](unsigned char c) {
                             return c == '\t' || (c >= 32 && c < 127);
                         })) {
            throw std::runtime_error("expected HTTP/1.1 status 101");
        }
        std::map<std::string, std::string> fields;
        std::size_t cursor = first_end + 2;
        while (cursor < boundary) {
            const auto line_end = response.find("\r\n", cursor);
            const std::string_view line(response.data() + cursor,
                                        line_end - cursor);
            const auto colon = line.find(':');
            if (colon == std::string_view::npos || colon == 0 ||
                !std::all_of(line.begin(), line.begin() + colon, token_char)) {
                throw std::runtime_error("invalid handshake header name");
            }
            const std::string name = lower(std::string(line.substr(0, colon)));
            const auto raw_value = line.substr(colon + 1);
            if (!std::all_of(raw_value.begin(), raw_value.end(),
                             [](unsigned char c) {
                                 return c == '\t' || (c >= 32 && c < 127);
                             })) {
                throw std::runtime_error("invalid handshake header value");
            }
            const auto value = trim(raw_value);
            if (name == "sec-websocket-protocol" ||
                name == "sec-websocket-extensions" ||
                name == "content-length" || name == "transfer-encoding") {
                throw std::runtime_error(
                    "unexpected negotiation or HTTP body framing");
            }
            if (name == "upgrade" || name == "connection" ||
                name == "sec-websocket-accept") {
                if (!fields.emplace(name, value).second) {
                    throw std::runtime_error("duplicate handshake field");
                }
            }
            cursor = line_end + 2;
        }
        if (!fields.contains("upgrade") || !fields.contains("connection") ||
            !fields.contains("sec-websocket-accept") ||
            lower(fields.at("upgrade")) != "websocket" ||
            fields.at("sec-websocket-accept") != expected_accept) {
            throw std::runtime_error(
                "invalid WebSocket upgrade or accept value");
        }
        bool upgrade_token = false;
        std::string_view options = fields.at("connection");
        for (;;) {
            const auto comma = options.find(',');
            const auto option = trim(options.substr(0, comma));
            if (option.empty() ||
                !std::all_of(option.begin(), option.end(), token_char)) {
                throw std::runtime_error("invalid Connection token list");
            }
            upgrade_token |= lower(std::string(option)) == "upgrade";
            if (comma == std::string_view::npos) {
                break;
            }
            options.remove_prefix(comma + 1);
        }
        if (!upgrade_token) {
            throw std::runtime_error("Connection lacks Upgrade token");
        }
        check_deadline();
        // Start with bytes received alongside the HTTP header terminator.
        std::string pending = response.substr(boundary + 4);
        const auto send_frame = [&](unsigned char opcode,
                                    std::string_view payload,
                                    bool final = true) {
            if (payload.size() > payload_limit ||
                (opcode >= 0x8 && (payload.size() > 125 || !final))) {
                throw std::runtime_error("outgoing frame exceeds profile");
            }
            std::array<unsigned char, 4> mask{};
            if (RAND_bytes(mask.data(), mask.size()) != 1) {
                throw std::runtime_error("cannot generate frame mask");
            }
            std::string frame;
            frame.push_back(static_cast<char>((final ? 0x80 : 0) | opcode));
            const std::uint64_t size = payload.size();
            if (size <= 125) {
                frame.push_back(static_cast<char>(0x80 | size));
            } else {
                const int bytes = size <= 65535 ? 2 : 8;
                frame.push_back(
                    static_cast<char>(0x80 | (bytes == 2 ? 126 : 127)));
                for (int shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
                    frame.push_back(static_cast<char>((size >> shift) & 0xff));
                }
            }
            frame.append(reinterpret_cast<const char*>(mask.data()),
                         mask.size());
            for (std::size_t index = 0; index < payload.size(); ++index) {
                frame.push_back(static_cast<char>(
                    static_cast<unsigned char>(payload[index]) ^
                    mask[index % 4]));
            }
            send_bytes(frame);
        };
        const auto read_more = [&] {
            for (;;) {
                check_deadline();
                const auto count = receive_bytes(buffer.data(), buffer.size());
                pending.append(buffer.data(), count);
                if (pending.size() > payload_limit + 10 + buffer.size()) {
                    throw std::runtime_error(
                        "frame buffer exceeds bounded read-ahead");
                }
                return;
            }
        };
        const auto read_frame = [&] {
            while (pending.size() < 2) {
                read_more();
            }
            const auto first = static_cast<unsigned char>(pending[0]);
            const auto second = static_cast<unsigned char>(pending[1]);
            const unsigned char opcode = first & 15;
            const bool final = (first & 0x80) != 0;
            if ((first & 0x70) != 0 ||
                (opcode != 0 && opcode != 1 && opcode != 8 && opcode != 9 &&
                 opcode != 10)) {
                throw std::runtime_error("unsupported opcode or reserved bits");
            }
            if (opcode >= 8 && !final) {
                throw std::runtime_error("control frames cannot be fragmented");
            }
            const unsigned char marker = second & 0x7f;
            if ((second & 0x80) != 0) {
                throw std::runtime_error("server frames must not be masked");
            }
            if ((first & 0x08) != 0 && marker > 125) {
                throw std::runtime_error(
                    "control payload must be at most 125 bytes");
            }
            const std::size_t extended = marker == 126   ? 2
                                         : marker == 127 ? 8
                                                         : 0;
            const std::size_t header = 2 + extended;
            while (pending.size() < header) {
                read_more();
            }
            std::uint64_t advertised = marker;
            if (extended != 0) {
                if (extended == 8 &&
                    (static_cast<unsigned char>(pending[2]) & 0x80) != 0) {
                    throw std::runtime_error(
                        "64-bit length has its high bit set");
                }
                advertised = 0;
                for (std::size_t i = 2; i < header; ++i) {
                    advertised = (advertised << 8) |
                                 static_cast<unsigned char>(pending[i]);
                }
                if ((extended == 2 && advertised < 126) ||
                    (extended == 8 && advertised < 65536)) {
                    throw std::runtime_error("nonminimal payload length");
                }
            }
            // Validate before narrowing, adding, allocating or waiting for
            // body.
            if (advertised > payload_limit) {
                throw std::runtime_error("payload exceeds 65536-byte policy");
            }
            const auto size = static_cast<std::size_t>(advertised);
            while (pending.size() < header + size) {
                read_more();
            }
            std::string payload = pending.substr(header, size);
            pending.erase(0, header + size);
            return std::tuple{opcode, final, payload};
        };
        std::array<unsigned char, 8> token_bytes{};
        if (RAND_bytes(token_bytes.data(), token_bytes.size()) != 1) {
            throw std::runtime_error("cannot generate Ping token");
        }
        const std::string ping_token(
            reinterpret_cast<const char*>(token_bytes.data()),
            token_bytes.size());
        bool pong_received = false;
        unsigned int ping_pong_count = 0;
        unsigned int pings_replied = 0;
        unsigned int pongs_ignored = 0;
        const auto handle_control = [&](unsigned char opcode,
                                        const std::string& payload) {
            if (opcode != 0x9 && opcode != 0xa) {
                return false;
            }
            if (++ping_pong_count > 32) {
                throw std::runtime_error("Ping/Pong frame budget exceeded");
            }
            if (opcode == 0x9) {
                // Preserve arbitrary bytes, including NUL, and mask our reply.
                send_frame(0xa, payload);
                ++pings_replied;
            } else if (!pong_received && payload == ping_token) {
                pong_received = true;
            } else {
                // A Pong can be unsolicited. Never reply to it with another
                // Pong.
                ++pongs_ignored;
            }
            return true;
        };
        send_frame(0x9, ping_token);
        // One text message, three independently masked frames.
        const std::string_view outgoing = message;
        const auto third = outgoing.size() / 3;
        send_frame(0x1, outgoing.substr(0, third), false);
        send_frame(0x0, outgoing.substr(third, third), false);
        send_frame(0x0, outgoing.substr(third * 2), true);
        bool echo_received = false;
        bool assembling = false;
        unsigned int data_frames = 0;
        std::string assembled;
        while (!echo_received || !pong_received) {
            const auto [opcode, final, payload] = read_frame();
            if (handle_control(opcode, payload)) {
                continue; // Control FIN does not finish a data message.
            }
            if (opcode >= 8 || echo_received) {
                throw std::runtime_error(
                    "expected one text message before Close");
            }
            if ((opcode == 0 && !assembling) || (opcode == 1 && assembling)) {
                throw std::runtime_error("invalid continuation state");
            }
            if (++data_frames > 64) {
                throw std::runtime_error("data-frame budget exceeded");
            }
            if (payload.size() > payload_limit - assembled.size()) {
                throw std::runtime_error("message exceeds 65536-byte policy");
            }
            assembled.append(payload); // Append data, never control payloads.
            assembling = !final;
            if (final) {
                if (assembled != message) {
                    throw std::runtime_error(
                        "expected exact ASCII message echo");
                }
                echo_received = true;
            }
        }
        const std::string normal_close{"\x03\xe8", 2};
        send_frame(0x8, normal_close);
        for (;;) {
            const auto [opcode, final, payload] = read_frame();
            // We sent Close but have not received it: Ping still needs Pong.
            if (handle_control(opcode, payload)) {
                continue;
            }
            if (opcode != 0x8 || !final || payload != normal_close) {
                throw std::runtime_error(
                    "expected Close with code 1000 and no reason");
            }
            break;
        }
        if (!pending.empty()) {
            throw std::runtime_error("data after Close");
        }
        // Reject application bytes after WebSocket Close. Read through the
        // peer's authenticated TLS EOF before starting our shutdown.
        for (;;) {
            check_deadline();
            char extra = 0;
            std::size_t received = 0;
            ERR_clear_error();
            const int result = SSL_read_ex(tls, &extra, 1, &received);
            if (result == 1) {
                throw std::runtime_error("data after WebSocket Close");
            }
            const int error_code = SSL_get_error(tls, result);
            if (error_code == SSL_ERROR_ZERO_RETURN) {
                break; // The peer sent close_notify, not merely TCP FIN.
            }
            if (error_code == SSL_ERROR_WANT_READ) {
                wait_for(POLLIN);
            } else if (error_code == SSL_ERROR_WANT_WRITE) {
                wait_for(POLLOUT);
            } else {
                throw std::runtime_error(
                    "TLS ended without a clean close_notify");
            }
        }
        // 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=localhost protocol=" << SSL_get_version(tls)
                  << " alpn=http/1.1\n"
                  << "echo_bytes=" << assembled.size() << '\n'
                  << "sent_frames=3 received_frames=" << data_frames << '\n'
                  << "matched_pong=yes pings_replied=" << pings_replied
                  << " pongs_ignored=" << pongs_ignored
                  << "\nclose=1000 tls_close_notify=yes\n";
    } 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;
}

Close WebSocket and TLS separately

A WebSocket Close frame is application data carried through TLS. Receiving Close 1000 does not mean that TLS shutdown or TCP teardown has completed.

This fixture initiates TLS shutdown after its WebSocket Close. The client first drains TLS reads, rejecting any further application byte, and requires SSL_ERROR_ZERO_RETURN for the peer's authenticated close_notify. It then calls SSL_shutdown() to send its own alert and complete both TLS directions within the same deadline.

An abrupt TCP disconnect after both WebSocket Close frames still fails this strict clean-TLS-shutdown policy. It does not erase the fact that the WebSocket Close exchange already occurred. Conversely, a TLS close_notify midway through a fragmented message does not finish that message.

The success line reports TLS closure, not an observed raw TCP EOF. A peer that waits indefinitely for us to initiate TLS shutdown is outside this fixture's server-first termination profile and will time out here. See OpenSSL shutdown semantics.

Compile directly and run checks

From network/, compile the one file with both SSL and Crypto libraries:

1
2
3
4
mkdir -p build/direct-wss
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/wss/client.cpp -o build/direct-wss/wss_client \
    $(pkg-config --cflags --libs openssl)

Use ./build/direct-wss/wss_client 9445 "$wss_certs/ca.pem" against a fresh fixture.

Without a manual fixture, run:

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

Independent Python TLS peers decode masks and compare message bytes without importing the fixture's frame codec. Checks retain single/fragmented/empty/64 KiB messages, continuation-state and resource-limit failures, interleaved Ping/Pong, split/coalesced application input and fixed deadlines.

TLS-specific cases use an unrelated CA, a trusted certificate for the wrong DNS name, absent/malformed trust files, TLS 1.2, a plaintext peer and absent/incompatible ALPN. Rejected identity/protocol cases must receive zero application bytes, not merely produce a nonzero client exit code. Other cases separate invalid Upgrade, incomplete Upgrade, TLS EOF mid-message, missing WebSocket Close, abrupt post-Close TCP loss and delayed TLS shutdown.

Verification scope

Verified on 23 October 2026 on a Raspberry Pi 4B running 64-bit Raspberry Pi OS (Debian 13), plus a Linux x86-64 host. All 563 course checks passed in Debug and Release on both machines; 79 exercise WSS. Twenty-nine representative direct-build cases also passed on both. The Pi reused isolated official OpenSSL headers and existing runtime libraries without system package or trust changes. Testing covered loopback behavior, not LAN performance or a cryptographic audit.

Keep the reduced profile visible

The default session deadline is fixed after local CA/TLS/nonce setup, before TCP connect. It covers TLS handshake, Upgrade, message/control processing and TLS shutdown. Neither handshake progress nor incoming application bytes renews it.

The earlier limits remain: 4096-byte Upgrade headers, 65,536-byte individual frame and assembled message caps, at most 64 incoming data frames and 32 incoming Ping/Pong frames, and a 65,610-byte logical pending-buffer cap. TLS library buffers, certificates, outgoing data and copies consume additional memory; those are not a total process-memory limit.

General UTF-8/binary messages, arbitrary Close payloads, full protocol-error Close replies, redirects, proxies, reconnects, client authentication and a concurrent general-purpose event loop remain outside this exercise. Unsupported input aborts without claiming full WebSocket conformance. Use a maintained high-level client for a production application rather than deploying this teaching parser.

Troubleshooting

  • Cannot load CA file: use the printed absolute ca.pem path, not the server certificate or a missing terminal-A variable.
  • Certificate failure: check trust, hostname and validity dates. The expected DNS identity stays localhost, even though the socket address is 127.0.0.1. Do not turn verification off.
  • Server did not select HTTP/1.1: this sample requires explicit ALPN selection. Use the supplied fixture; a server that omits ALPN can be outside the sample policy without being universally invalid WSS.
  • Invalid Upgrade after successful TLS: inspect the HTTP 101/negotiation fields. Certificate validation does not validate WebSocket responses.
  • TLS ended without clean close_notify: distinguish completed WebSocket Close from abrupt TLS termination.
  • Deadline after WebSocket Close: the peer may not have initiated TLS shutdown as this fixture does. A bigger payload or completed echo does not reset the timer.
  • Missing library symbols: direct compilation needs openssl, not only libcrypto.

Check your understanding

  1. Does Sec-WebSocket-Accept verify the server's DNS identity?
  2. Does SNI alone enable certificate hostname checking?
  3. Should a client send Upgrade after failed certificate or ALPN checks?
  4. Are WebSocket client masks still required inside TLS?
  5. Can a TLS read need write readiness?
  6. Are WebSocket Close, TLS close_notify and TCP EOF interchangeable?
Answers

Identity checking belongs to TLS certificate verification, not the accept hash or SNI alone. This client sends no Upgrade after either gate fails. TLS does not remove client masking. TLS operations may need the opposite I/O direction. The three termination signals belong to different layers and must not be confused.

Continue the course

Previous: Fragmented Messages · Course overview

This completes the course's first bounded WS/WSS path. Next: Your First Verified DTLS Exchange returns to datagrams and separates protection from delivery guarantees.

References: WebSocket client opening handshake, TLS hostname verification, ALPN selection, TLS error handling.

Donate