DEV Community

POTHURAJU JAYAKRISHNA YADAV
POTHURAJU JAYAKRISHNA YADAV

Posted on

Understanding TLS from Scratch: A Hands-On, Step-by-Step Guide

This is not a polished crypto textbook.
This is exactly how I, as a DevOps engineer, tried to understand TLS by breaking it down, running commands, hitting errors, and finally connecting the dots.

I had worked with HTTPS, load balancers, certificates, and Kubernetes ingress for a long time — but if someone asked me “what exactly happens inside TLS?”, my answer used to be hand-wavy.

So I decided to rebuild TLS step by step using OpenSSL, starting from old TLS 1.1, moving to modern TLS 1.2 (ECDHE), and finally encrypting real data.

This blog documents that journey — including confusions, mistakes, and fixes.


Why I Started This

TLS is everywhere:

  • ALBs
  • Application Gateways
  • NGINX
  • Kubernetes Ingress
  • Service Meshes

Yet most explanations stop at:

“TLS uses asymmetric encryption for handshake and symmetric encryption for data.”

That explanation never satisfied me.

I wanted to answer:

  • Where does the key actually come from?
  • Is the session key sent or generated?
  • What changed between TLS 1.1, 1.2, and 1.3?

So I started from the old way first.


PART 1 — TLS 1.1 (RSA): Understanding the Old Model

Before ECDHE, TLS relied on RSA key exchange. This is important to understand because modern TLS exists specifically to fix its problems.

High-level idea (TLS 1.1)

  1. Server has an RSA key pair
  2. Server shares the public key
  3. Client generates a secret
  4. Client encrypts that secret using server public key
  5. Server decrypts using private key
  6. Both derive the same session key

At first glance, this feels reasonable.


Step 1: Server generates RSA key pair

openssl genpkey -algorithm RSA -out server_private.pem -pkeyopt rsa_keygen_bits:2048
Enter fullscreen mode Exit fullscreen mode

When I ran this, it clicked:

  • This private key is long-term
  • It lives on the server
  • If this key leaks, things get dangerous

Extract the public key:

openssl pkey -in server_private.pem -pubout -out server_public.pem
Enter fullscreen mode Exit fullscreen mode

This public key is what eventually goes inside the TLS certificate.


Step 2: Client generates Pre-Master Secret

In real TLS, this is random binary data. For learning, I used text:

echo "tls11-pre-master-secret" > pre_master_secret.txt
Enter fullscreen mode Exit fullscreen mode

At this point I clearly understood:

This secret is the root of all security in TLS 1.1


Step 3: Client encrypts secret using server public key

openssl pkeyutl -encrypt \
  -pubin \
  -inkey server_public.pem \
  -pkeyopt rsa_padding_mode:oaep \
  -in pre_master_secret.txt \
  -out pre_master_secret.enc
Enter fullscreen mode Exit fullscreen mode

This step is exactly what TLS 1.1 does.

Important realization:

  • The secret is actually sent over the network (encrypted)
  • Anyone who later gets the private key can decrypt old traffic

Step 4: Server decrypts using private key

openssl pkeyutl -decrypt \
  -inkey server_private.pem \
  -pkeyopt rsa_padding_mode:oaep \
  -in pre_master_secret.enc \
  -out recovered_pre_master.txt
Enter fullscreen mode Exit fullscreen mode

Now both sides have the same secret.

At this moment I thought:

“So TLS security fully depends on this private key never leaking.”

And that’s exactly the problem.


Why TLS 1.1 Was Broken

After doing this hands-on, the problems became obvious:

  • ❌ No forward secrecy
  • ❌ Past traffic can be decrypted if key leaks
  • ❌ Vulnerable to padding oracle attacks

This is why RSA key exchange was deprecated.

Modern TLS had to change the model completely.


PART 2 — TLS 1.2 (ECDHE): The Modern Fix

This is where things finally made sense.

The key idea of ECDHE is:

Never send the secret. Generate it independently on both sides.


Step 5: Client generates ephemeral EC key pair

openssl genpkey -algorithm EC \
  -pkeyopt ec_paramgen_curve:prime256v1 \
  -out client_private.pem
Enter fullscreen mode Exit fullscreen mode

Extract public key:

openssl pkey -in client_private.pem -pubout -out client_public.pem
Enter fullscreen mode Exit fullscreen mode

This key pair exists only for this connection.


Step 6: Server generates ephemeral EC key pair

openssl genpkey -algorithm EC \
  -pkeyopt ec_paramgen_curve:prime256v1 \
  -out server_private.pem
Enter fullscreen mode Exit fullscreen mode
openssl pkey -in server_private.pem -pubout -out server_public.pem
Enter fullscreen mode Exit fullscreen mode

At this point:

  • Client has its EC key pair
  • Server has its EC key pair

Only public keys will be exchanged.


Step 7: Deriving the shared secret (this was the “aha” moment)

Client side:

openssl pkeyutl -derive \
  -inkey client_private.pem \
  -peerkey server_public.pem \
  -out client_shared_secret.bin
Enter fullscreen mode Exit fullscreen mode

Server side:

openssl pkeyutl -derive \
  -inkey server_private.pem \
  -peerkey client_public.pem \
  -out server_shared_secret.bin
Enter fullscreen mode Exit fullscreen mode

When I verified both:

sha256sum client_shared_secret.bin server_shared_secret.bin
Enter fullscreen mode Exit fullscreen mode

…and saw the hashes match, everything clicked.

No secret was sent. Yet both sides got the same value.

That is the heart of modern TLS.


Shared Secret ≠ Session Key (Important Realization)

Initially I assumed this shared secret was the session key.

It is not.

TLS treats this as input material, not the final key.


Step 8: Deriving a symmetric key

TLS uses HKDF internally. For learning, I hashed the shared secret:

openssl sha256 client_shared_secret.bin
Enter fullscreen mode Exit fullscreen mode

This produced a 32-byte value — perfect for AES-256.

This is conceptually the application traffic key.


PART 3 — Encrypting Real Data

Now came the most satisfying part: actually encrypting data.

Plaintext

echo "Hello secure world from client" > message.txt
Enter fullscreen mode Exit fullscreen mode

Encrypt (AES)

openssl enc -aes-256-cbc \
  -K <derived_key_hex> \
  -iv 00000000000000000000000000000000 \
  -in message.txt \
  -out message.enc
Enter fullscreen mode Exit fullscreen mode

Decrypt on the other side

openssl enc -d -aes-256-cbc \
  -K <derived_key_hex> \
  -iv 00000000000000000000000000000000 \
  -in message.enc \
  -out message.dec
Enter fullscreen mode Exit fullscreen mode

Seeing the original message come back confirmed:

I just implemented the core of TLS myself.


Where TLS 1.3 Fits In

TLS 1.3 removes all legacy paths:

  • No RSA key exchange
  • Only ECDHE
  • Faster handshake

But conceptually, the ECDHE flow you saw here is exactly what TLS 1.3 uses.


Final Takeaway

Doing this hands-on changed how I think about TLS:

  • TLS is not magic
  • Keys are not sent
  • Security comes from math + randomness
  • Symmetric encryption does the heavy lifting

If you are a DevOps engineer working with HTTPS every day, this depth of understanding is worth it.


This blog reflects real learning, real commands, and real confusion along the way — exactly how engineering understanding is built.

Top comments (0)