The Brief
3 minBuild a multi-client chat over TLS on localhost:
- Server — accepts TLS connections, runs each client in a thread, broadcasts every message to all others.
- Client — connects over TLS (verifying the cert), sends typed lines, prints incoming messages.
- Framing — length-prefixed messages (Lesson 7) so the stream is parsed correctly.
- Security — TLS transport, input limits, no eval, graceful disconnects.
Every piece — sockets, threads, framing, TLS — you've already built. The project is wiring them into one coherent, secure system. This is also a great template for any real-time tool: notifications, a game lobby, an IoT controller.
Architecture
5 min ┌──────── TLS server (127.0.0.1:9443) ────────┐
Client A ◄══TLS══► accept → thread A ─┐ │
Client B ◄══TLS══► accept → thread B ─┼─► shared client list │
Client C ◄══TLS══► accept → thread C ─┘ broadcast to all │
└─────────────────────────────────────────────┘
Each client thread: recv a framed message → broadcast to every OTHER client.
All traffic is TLS-encrypted on the wire; framing keeps messages intact.Multiple threads share the list of connected clients, so adding/removing/broadcasting must be guarded with a lock to avoid races. That's the one genuinely tricky bit — handle it with a threading.Lock around the client set.
Build It · The TLS Chat Server
14 minFraming helpers (from Lesson 7)
import struct MAX_MSG = 8192 # cap message size (DoS defence) def send_msg(sock, text: str) -> None: data = text.encode("utf-8")[:MAX_MSG] sock.sendall(struct.pack("!I", len(data)) + data) def _recv_exact(sock, n: int) -> bytes: buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("closed") buf += chunk return buf def recv_msg(sock) -> str: (length,) = struct.unpack("!I", _recv_exact(sock, 4)) if length > MAX_MSG: raise ValueError("message too large") return _recv_exact(sock, length).decode("utf-8", errors="replace")
The server
import socket, ssl, threading, logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") log = logging.getLogger("chat") clients: dict = {} # tls_sock -> name clients_lock = threading.Lock() def broadcast(message: str, sender=None) -> None: with clients_lock: dead = [] for sock, name in clients.items(): if sock is sender: continue try: send_msg(sock, message) except (OSError, ConnectionError): dead.append(sock) for sock in dead: # prune clients that errored clients.pop(sock, None) def handle(tls_sock, addr): try: tls_sock.settimeout(300) name = recv_msg(tls_sock).strip()[:20] or f"anon-{addr[1]}" with clients_lock: clients[tls_sock] = name log.info("%s joined (%s)", name, addr) broadcast(f"* {name} joined the chat *") while True: msg = recv_msg(tls_sock).strip()[:500] if not msg: continue if msg.lower() == "/quit": break broadcast(f"{name}: {msg}", sender=tls_sock) except (ConnectionError, ValueError, ssl.SSLError, socket.timeout) as e: log.warning("%s error: %s", addr, e) finally: with clients_lock: name = clients.pop(tls_sock, "someone") tls_sock.close() broadcast(f"* {name} left *") log.info("%s left", name) def main(): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain("cert.pem", "key.pem") # self-signed lab cert (L8-09) with socket.create_server(("127.0.0.1", 9443)) as raw: log.info("encrypted chat on 127.0.0.1:9443 (TLS)") while True: conn, addr = raw.accept() try: tls = ctx.wrap_socket(conn, server_side=True) except ssl.SSLError as e: log.warning("TLS handshake failed from %s: %s", addr, e) conn.close(); continue threading.Thread(target=handle, args=(tls, addr), daemon=True).start() if __name__ == "__main__": main()
Note the discipline: the shared clients dict is always touched under clients_lock; broadcasts prune dead sockets; message size and rate are bounded; and a failed TLS handshake is logged and dropped rather than crashing the accept loop.
Build It · The TLS Chat Client
12 minThe client needs two things happening at once: reading incoming messages and reading your keyboard. A reader thread handles the socket; the main thread handles input.
import socket, ssl, threading, sys def reader(tls_sock): """Background thread: print messages as they arrive.""" try: while True: print("\r" + recv_msg(tls_sock) + "\n> ", end="", flush=True) except (ConnectionError, ValueError, OSError): print("\n[disconnected]") sys.exit(0) def main(): name = input("Your name: ").strip() or "anon" # verify the server's cert against OUR lab cert (verification stays ON) ctx = ssl.create_default_context() ctx.load_verify_locations("cert.pem") ctx.check_hostname = True with socket.create_connection(("127.0.0.1", 9443), timeout=10) as raw: with ctx.wrap_socket(raw, server_hostname="localhost") as tls: print(f"connected over {tls.version()} ✔") send_msg(tls, name) # first message = our name threading.Thread(target=reader, args=(tls,), daemon=True).start() while True: line = input("> ") send_msg(tls, line) if line.lower() == "/quit": break if __name__ == "__main__": main()
# terminal 1: python server.py
14:30:00 encrypted chat on 127.0.0.1:9443 (TLS)
14:30:05 aisha joined (('127.0.0.1', 51500))
14:30:09 ben joined (('127.0.0.1', 51502))
# terminal 2 (aisha):
connected over TLSv1.3 ✔
> hi everyone
ben: hey aisha!
# terminal 3 (ben):
connected over TLSv1.3 ✔
* aisha joined the chat *
aisha: hi everyone
> hey aisha!Read the result
You built a genuinely secure real-time system from primitives: TLS encrypts every byte on the wire (a sniffer sees only ciphertext — verify with Lesson 24's teaser), length-prefix framing keeps messages intact across the TCP stream, threads serve many clients, and a lock keeps the shared client list race-free. The client keeps TLS verification on, trusting only your lab cert — never verify=False. Swap the self-signed cert for a real one and bind beyond localhost (carefully, behind a firewall) and the same code is a real chat service.
Build It Yourself
13 minGenerate your lab cert (Lesson 9), run the server, and connect two clients in separate terminals. Confirm messages broadcast and join/leave notices appear.
Add /who (list connected names) and /nick <name> (change your name, broadcasting the change). Use a whitelist dispatch — never execute arbitrary input.
Hint
if msg == "/who": with clients_lock: names = ", ".join(clients.values()) send_msg(tls_sock, f"* online: {names} *") continue
Run a plain (non-TLS) version on a different port, then the TLS version. Use a tool you control to observe the difference (or reason it out): the plain version's messages would be readable on the wire; the TLS version's are ciphertext. Write up why TLS matters here even on "just localhost."
Hint
On localhost a sniffer is unlikely, but the point is the habit: the same code over real networks must be TLS. "Secure by default" means you never ship the plaintext version and forget to add TLS later.
Stretch · Add a Shared Passphrase (App-Layer Auth)
8 minTLS proves you're talking to your server, but anyone with the client could join. Add an application-layer check: the client must send a shared passphrase as its first message; the server compares it with hmac.compare_digest (constant-time, to resist timing attacks — Lesson 12) and drops clients that fail. A taste of authentication on top of secure transport.
Show the key additions
import os, hmac ROOM_SECRET = os.environ["CHAT_SECRET"].encode() # from env, never hard-coded def handle(tls_sock, addr): try: tls_sock.settimeout(30) attempt = recv_msg(tls_sock).encode() # constant-time compare prevents leaking the secret via timing if not hmac.compare_digest(attempt, ROOM_SECRET): send_msg(tls_sock, "wrong passphrase") log.warning("auth fail from %s", addr) tls_sock.close(); return send_msg(tls_sock, "ok") # ...proceed to the name + chat loop as before... except Exception as e: log.warning("%s: %s", addr, e); tls_sock.close()
Non-negotiables: secret from env, hmac.compare_digest (not ==), failed clients dropped + logged.
Recap
3 minThe encrypted chat composes the whole networking arc: sockets (7) for transport, a threaded server (8) for many clients, length-prefix framing (7) to parse the TCP stream, and TLS (9) for confidentiality, authentication, and integrity — with verification kept on by trusting your own lab cert. The one tricky part, shared client state across threads, is solved with a lock. Security defaults are baked in: size caps, timeouts, whitelisted commands, no eval. Add app-layer auth (a passphrase compared in constant time) and you've layered authentication over secure transport — defence in depth in a 50-line app.
Vocabulary Card
- broadcast
- Sending one message to all connected clients.
- shared state + lock
- Data many threads touch; a lock prevents race conditions.
- app-layer auth
- Authentication on top of TLS (TLS proves the server; this proves the client).
- hmac.compare_digest
- Constant-time comparison that doesn't leak via timing.
Homework
4 minFinish the encrypted chat with at least the base broadcast working over TLS and two stretch features (commands + passphrase auth). Run it with three clients. Write a short security note: list each control (TLS, framing cap, timeout, lock, constant-time auth, whitelist) and the threat it addresses, mapping each to the CIA pillar it protects.
Sample · chat security note
Control → threat addressed → CIA TLS transport → eavesdropping / tampering → C + I cert verification → connecting to a spoofed server → I (authenticity) length cap (8KB) → memory-exhaustion DoS → A idle timeout (300s) → resource-holding DoS → A clients_lock → race conditions corrupting state → I passphrase + compare_digest → unauthorised clients / timing leak → C whitelist commands → command/code injection → I secret from env → credential leak in code/git → C Three clients tested; join/leave + broadcast + /who + auth all work.
Non-negotiables: working TLS chat with two stretch features, and each control mapped to its threat and CIA pillar.