A year ago I wrote a series about how a web server works.
I started from a very primitive version and step by step moved toward the same core ideas modern production servers rely on. When I finished that series, I thought the next step would be small.
Wrap it in TLS. Make the communication secure.
It did not stay small for long.
What looked like a thin security layer on top of an existing server turned into a much deeper journey into cryptography, authentication, trust, certificates, protocol design, and many details usually hidden behind one familiar phrase: secure connection.
So this series is my attempt to approach TLS the same way I approached the web server: not as a finished black box, but as something we can rebuild from simpler pieces until its shape starts to make sense.
In this first part, we will start with the most naive version of the problem.
We will build a very simple socket-based communication channel, see that it is fully transparent, wrap it in encryption with a shared secret key, and then see why that is still not enough.
That will give us our first fake secure channel.
And that is exactly where we should start.
What we will build in this part
The plan for this article is simple:
- build a tiny socket-based client and server
- send plain text between them
- look at the traffic and see that everything is visible
- add shared-key encryption with AES-CTR
- make the traffic unreadable
- then show why encryption alone still does not give us a trustworthy secure protocol
We are not trying to build real TLS yet.
We are trying to make the first mistake on purpose.
Because once that mistake becomes visible, the next piece of the protocol stops looking optional.
TLS is not SSL
Before we start, one small clarification.
People still often say “SSL” when they talk about secure communication on the web. But SSL is the older family of protocols. TLS is its successor.
So when people say things like “SSL certificate” or “SSL connection,” in practice they usually mean TLS.
For modern systems, the relevant protocols are TLS, especially TLS 1.2 and TLS 1.3. This series is about understanding the ideas behind TLS by rebuilding simpler versions of the problems it solves.
And instead of starting from the finished protocol, we will begin one layer lower — with plain socket communication.
Step 1 — A plain socket-based communication channel
Let’s start with the smallest possible thing: a tiny TCP server and a tiny TCP client.
The client will send an HTTP-like request.
The server will read it and return an HTTP-like response.
Nothing secure yet. Just raw bytes moving over a socket.
Plain server
# server_plain.py
import socket
HOST = "127.0.0.1"
PORT = 8081
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.bind((HOST, PORT))
server.listen(1)
print(f"Listening on {HOST}:{PORT}")
conn, addr = server.accept()
with conn:
print(f"Connected by {addr}")
data = conn.recv(4096)
request = data.decode("utf-8")
print("Received request:")
print(request)
response = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 13\r\n"
"\r\n"
"hello, client"
)
conn.sendall(response.encode("utf-8"))
Plain client
# client_plain.py
import socket
HOST = "127.0.0.1"
PORT = 8081
request = (
"GET /transfer?to=bob&amount=100 HTTP/1.1\r\n"
"Host: localhost\r\n"
"\r\n"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.connect((HOST, PORT))
client.sendall(request.encode("utf-8"))
response = client.recv(4096)
print("Received response:")
print(response.decode("utf-8"))
This is intentionally tiny.
The client sends a request like this:
GET /transfer?to=bob&amount=100 HTTP/1.1
Host: localhost
The server reads it and sends a response back.
That is all.
And because it is all plain TCP, anyone who can observe the traffic can read it directly.
Looking at the traffic
If you capture this communication in Wireshark, the request and response are fully visible in clear text.
That is our baseline.
The client can read it.
The server can read it.
And anyone on the wire can read it too.
So the first obvious idea is also the first naive one:
If the problem is that everyone can read the bytes, let’s encrypt the bytes.
That sounds reasonable.
And it is still not enough.
Step 2 — Turning bytes into records
Before we add encryption, we need one small but important thing: structure.
TCP gives us a byte stream.
It does not give us message boundaries.
So once we stop sending plain text directly and start sending encrypted blobs, we need a way to tell the receiver how many bytes belong to one logical message.
That means even before security, we need a little bit of protocol design.
Let’s define the smallest possible record format:
- 4 bytes: payload length
- N bytes: payload
+----------+-----------+
| length | payload |
| (4 bytes)| (varies) |
+----------+-----------+
That is enough for our first version.
# framing.py
import struct
def send_record(sock, payload: bytes) -> None:
header = struct.pack("!I", len(payload))
sock.sendall(header + payload)
def recv_exact(sock, n: int) -> bytes:
chunks = []
remaining = n
while remaining > 0:
chunk = sock.recv(remaining)
if not chunk:
raise ConnectionError("Connection closed while reading data")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def recv_record(sock) -> bytes:
header = recv_exact(sock, 4)
(length,) = struct.unpack("!I", header)
return recv_exact(sock, length)
This is not a crypto step.
It is a protocol step.
And that distinction matters more than it first appears. A secure channel is not just “call encrypt on a string.” It is a protocol with structure, state, and rules.
Now that we have a way to send and receive well-defined records, we can finally wrap them in encryption.
Step 3 — Wrapping the channel in shared-key encryption
The most obvious first attempt at secure communication is usually this:
- both sides already know the same secret key
- the sender encrypts the message before sending
- the receiver decrypts it after receiving
That is exactly what we will do.
No handshake yet.
No certificates yet.
No integrity yet.
No authentication yet.
This version is intentionally naive.
For encryption, I will use AES in CTR mode.
Very briefly:
- AES is a symmetric block cipher
- CTR mode makes it convenient for encrypting a stream of bytes
- it gives us confidentiality
- but it does not give us integrity
That last point is the important one for this article.
If you want a deeper explanation of AES itself, I already wrote about it in my cryptography series: https://www.dmytrohuz.com/p/building-own-block-cipher-part-3, so I will not go into the internals here.
The nonce
CTR mode also needs a nonce.
For now, think of it as a fresh per-message value that must be different for each encryption under the same key.
It is not secret.
It just must not be reused.
So our encrypted payload will look like this:
- nonce
- ciphertext
Crypto helper
# crypto.py
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# 32-byte shared key for AES-256
SHARED_KEY = b"0123456789ABCDEF0123456789ABCDEF"
def encrypt_message(plaintext: bytes) -> bytes:
nonce = os.urandom(16)
cipher = Cipher(algorithms.AES(SHARED_KEY), modes.CTR(nonce))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
# Encrypted payload format:
# nonce || ciphertext
return nonce + ciphertext
def decrypt_message(payload: bytes) -> bytes:
nonce = payload[:16]
ciphertext = payload[16:]
cipher = Cipher(algorithms.AES(SHARED_KEY), modes.CTR(nonce))
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext
At this point, our wire format becomes:
- 4-byte length
- 16-byte nonce
- ciphertext
+----------------+----------------+---------------------+
| length (4 B) | nonce (16 B) | ciphertext (N bytes)|
+----------------+----------------+---------------------+
That already looks much more like a protocol.
Step 4 — Encrypt the request and response
Now let’s integrate this into the client and server.
Encrypted client
# client_v1.py
import socket
from framing import send_record, recv_record
from crypto import encrypt_message, decrypt_message
HOST = "127.0.0.1"
PORT = 8081
request = (
"GET /transfer?to=bob&amount=100 HTTP/1.1\r\n"
"Host: localhost\r\n"
"\r\n"
).encode("utf-8")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.connect((HOST, PORT))
encrypted_request = encrypt_message(request)
send_record(client, encrypted_request)
encrypted_response = recv_record(client)
response = decrypt_message(encrypted_response)
print("Received decrypted response:")
print(response.decode("utf-8"))
Encrypted server
# server_v1.py
import socket
from framing import send_record, recv_record
from crypto import encrypt_message, decrypt_message
HOST = "127.0.0.1"
PORT = 8081
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.bind((HOST, PORT))
server.listen(1)
print(f"Listening on {HOST}:{PORT}")
conn, addr = server.accept()
with conn:
print(f"Connected by {addr}")
encrypted_request = recv_record(conn)
request = decrypt_message(encrypted_request)
print("Received decrypted request:")
print(request.decode("utf-8"))
response = (
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
"Content-Length: 13\r\n\r\nhello, client"
).encode("utf-8")
encrypted_response = encrypt_message(response)
send_record(conn, encrypted_response)
Now the communication flow changes in an important way.
Instead of sending readable HTTP-like text directly, the client sends an encrypted record. The server reads the record, decrypts it, and sees the original request.
What changes on the wire
If you capture this version in Wireshark, the traffic is no longer readable.
Instead of clear request and response text, you now see opaque binary data.
So yes, we gained something real.
Let’s stop and say exactly what that is.
What encryption actually gave us
This first version gives us one meaningful property:
confidentiality against passive observers
If someone can only observe the traffic, but cannot modify it, they no longer get the plaintext request and response for free.
That is already better than raw TCP.
And this is why “just add encryption” feels so convincing. It visibly solves a real problem.
But that visible success can hide another, more dangerous failure.
Because a secure channel needs more than secrecy.
It also needs protection against tampering.
And we still do not have that.
Step 5 — Why encryption alone is not enough
This is the real point of Part 1.
We encrypted the messages.
We did not make them trustworthy.
AES-CTR protects confidentiality, but it does not protect integrity.
That means an active attacker may be able to modify ciphertext, and those modifications will flow through into the decrypted plaintext.
Very roughly, CTR mode behaves like this:
ciphertext = plaintext XOR keystream
So if an attacker changes bits in the ciphertext, the corresponding bits change in the plaintext after decryption.
That property is called malleability.
And protocol messages are usually predictable enough that this becomes useful to an attacker.
Our example request already has a very predictable structure:
GET /transfer?to=bob&amount=100 HTTP/1.1
Host: localhost
The exact bytes of amount=100 are not random.
That predictability is enough to hurt us.
A tiny isolated demo
We do not need a full man-in-the-middle proxy to show the problem. A small isolated example is enough.
# ctr_malleability_demo.py
from crypto import encrypt_message, decrypt_message
original = b"amount=100"
encrypted = encrypt_message(original)
nonce = encrypted[:16]
ciphertext = bytearray(encrypted[16:])
# Change '1' -> '9'
# ASCII '1' = 0x31
# ASCII '9' = 0x39
# Difference = 0x08
index_of_digit = len("amount=")
ciphertext[index_of_digit] ^= 0x08
modified = nonce + bytes(ciphertext)
decrypted = decrypt_message(modified)
print("Original :", original)
print("Modified :", decrypted)
Output:
Original : b'amount=100'
Modified : b'amount=900'
And that is the failure.
The attacker did not need the key.
They did not need to fully decrypt the message first.
They only needed the ability to modify the encrypted bytes in transit.
The receiver then decrypts the modified ciphertext and gets modified plaintext — without any built-in indication that anything went wrong.
So even though the message is hidden from passive observers, it is still vulnerable to active tampering.
That is not a secure protocol.
That is only encrypted transport.
What is still broken
At this point, our fake secure channel still has many serious holes.
No integrity protection
The receiver cannot detect that the ciphertext was modified.
No message authentication
The receiver has no cryptographic proof that the message came from the expected sender and arrived unchanged.
No replay protection
An attacker can capture an encrypted message and replay it later.
Static shared key
Both sides use one long-term shared key for everything.
That does not scale, and if it leaks, everything built on top of it collapses.
No handshake
There is no fresh session establishment. The peers do not negotiate anything. They just start encrypting.
No peer identity
The client does not really know who it is talking to beyond “someone who can decrypt with this key.”
So yes, we improved something.
But we are still very far from TLS.
Good.
That is exactly what Part 1 should make visible.
Summary
In this first part, we built a fake secure channel.
We started with plain socket communication and saw that everything was fully transparent. Then we wrapped the communication in shared-key encryption with AES-CTR, which gave us confidentiality against passive observers.
That was real progress.
But it was not enough.
Because encrypting a message is not the same thing as protecting the message from being changed. Our channel still accepts modified ciphertext, decrypts it, and trusts the result.
So the first lesson of this series is simple:
confidentiality is not integrity
And if we want something that starts to deserve the name secure protocol, we need both.
Next part — adding integrity with a MAC
So we stop here.
We now have a channel that can hide bytes from passive observers, but cannot reliably detect tampering.
In the next part, we will keep the same basic setup and fix the biggest hole we exposed here: we will add a MAC — a Message Authentication Code.
That will take us from:
“you probably can’t read this”
to:
“you also can’t silently change this”
That still will not be real TLS.
But it will make our fake secure channel one step less fake.
Final code
I’ll put the full code for this part on GitHub here:
https://github.com/DmytroHuzz/rebuilding_tls/tree/main/part_1


Top comments (0)