Sitemap

TLS From Scratch: Building Certificates, ECDHE, and AES Encryption in Python

9 min readSep 1, 2026

--

A hands-on walkthrough of how HTTPS actually establishes trust and encrypts your data — with runnable Python at every step.

You have typed https:// a few hundred thousand times. Somewhere behind that padlock, three completely different cryptographic systems hand work off to each other in a strict order, and each one is doing a job the other two are bad at.

Most explanations stop at the diagram. This one doesn’t — we are going to build the whole thing in Python: a root certificate authority, an intermediate CA, a real leaf certificate for a fictional site, a client that genuinely verifies the chain and rejects an impostor, an elliptic-curve key exchange, and an AES-encrypted request and response that detects tampering.

Here are the three systems and the one thing each is for:

  • RSA — proves identity. This is how a Certificate Authority (CA) vouches for a server, and how a browser confirms it’s really talking to the domain it thinks it is.
  • ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) — agrees on a shared secret. Two parties who’ve never met can land on the same secret number, over a network an attacker is watching, without ever transmitting that secret.
  • AES — encrypts the actual data. Once trust is established and a shared secret exists, all real traffic is encrypted with this fast symmetric cipher.

The split exists because each tool is good at a different thing and bad at the others. RSA is slow — you’d never use it to encrypt megabytes of traffic. AES is fast but needs a shared key first — and you can’t just email that key without an eavesdropper reading it too. ECDHE solves the key-agreement problem without ever putting the secret on the wire.

Three algorithms, three jobs: RSA says who you are, ECDHE agrees on a secret, AES does the actual work.

By the end you’ll have built:

  • A self-signed root CA
  • An intermediate CA the root signs
  • A leaf certificate for a fictional site, bb.com, signed by the intermediate
  • A client that actually verifies that chain — signatures, validity, and hostname
  • A working ECDHE exchange that derives a real AES key
  • An encrypted request/response exchange, including a demonstration of tamper detection

Everything here needs exactly one dependency:

pip install cryptography

Every script below was run to produce the output shown — nothing here is hypothetical.

1. Generating the key pairs

Before anything else, every party needs its own key pair. Notice we generate two different kinds:

  • RSA key pairs — used purely for signing (identity, certificates).
  • EC key pairs — used purely for ECDHE (the key exchange later). These are a different algorithm entirely; RSA and EC keys are never interchangeable.
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization

def generate_rsa_keypair(path_prefix: str):
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with open(f"{path_prefix}.key", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
with open(f"{path_prefix}.pub", "wb") as f:
f.write(private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
))
return private_key

def generate_ec_keypair(path_prefix: str):
private_key = ec.generate_private_key(ec.SECP384R1())
with open(f"{path_prefix}.key", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
with open(f"{path_prefix}.pub", "wb") as f:
f.write(private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
))
return private_key

if __name__ == "__main__":
generate_rsa_keypair("root_rsa")
generate_rsa_keypair("intermediate_rsa")
generate_rsa_keypair("server_rsa")
generate_ec_keypair("server_ec")
generate_ec_keypair("client_ec")

Running this drops .key (private, never leaves its owner) and .pub files for the root, intermediate, and server (RSA), plus a pair of ephemeral-style EC keys we’ll actually regenerate fresh in section 5.

2. Generating the root and intermediate certificates

A certificate is just a signed statement: “this public key belongs to this identity.” The root signs itself (there’s nothing above it); the intermediate is signed by the root.

Get Jude Arun Selvan Jesuthasan’s stories in your inbox

Join Medium for free to get updates from this writer.

Two fields matter most for a CA certificate:

  • BasicConstraints(ca=True, path_length=...) — marks it as allowed to sign other certificates. path_length=0 on the intermediate means it can sign leaf certs but not create further intermediates below itself.
  • KeyUsage(key_cert_sign=True, crl_sign=True) — restricts the key to CA-type operations.
import datetime
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import load_pem_private_key

def load_key(path):
return load_pem_private_key(open(path, "rb").read(), password=None)

def make_ca_cert(subject_cn, issuer_name, subject_public_key, signing_key,
path_length, out_path, self_signed=False):
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example Trust Services"),
x509.NameAttribute(NameOID.COMMON_NAME, subject_cn),
])
issuer = subject if self_signed else issuer_name
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(subject_public_key)
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=path_length), critical=True)
.add_extension(x509.KeyUsage(
digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False,
key_cert_sign=True, crl_sign=True,
encipher_only=False, decipher_only=False,
), critical=True)
)
cert = builder.sign(private_key=signing_key, algorithm=hashes.SHA256())
with open(out_path, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
return cert

if __name__ == "__main__":
root_key = load_key("root_rsa.key")
root_cert = make_ca_cert(
subject_cn="Example Root CA", issuer_name=None,
subject_public_key=root_key.public_key(), signing_key=root_key,
path_length=None, out_path="root.crt", self_signed=True,
)
intermediate_key = load_key("intermediate_rsa.key")
intermediate_cert = make_ca_cert(
subject_cn="Example Intermediate CA 1", issuer_name=root_cert.subject,
subject_public_key=intermediate_key.public_key(), signing_key=root_key,
path_length=0, out_path="intermediate.crt", self_signed=False,
)

Verifying with OpenSSL confirms the chain is mathematically sound:

$ openssl verify -CAfile root.crt intermediate.crt
intermediate.crt: OK

Look closely at the two calls. The intermediate passes signing_key=root_key while its subject is the intermediate; the root passed root_key as the signer of itself. That’s the entire difference between “self-signed” and “CA-signed” — who supplies the signing key relative to whose identity is in the certificate. There is no other magic in a root CA.

3. bb.com requests a certificate

Now a real server — bb.com — wants a certificate. This happens in two independent steps, done by two different parties.

3a. The server builds a CSR

The server never sends its private key anywhere. It builds a Certificate Signing Request: its public key, its claimed identity, and a Subject Alternative Name (SAN) — the field browsers actually check against the URL (not the Common Name, despite what many tutorials still show). The CSR is self-signed with the server’s own key purely to prove it holds that key.

from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import load_pem_private_key

server_key = load_pem_private_key(open("server_rsa.key", "rb").read(), password=None)
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.COMMON_NAME, "bb.com"),
])
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(subject)
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("bb.com"), x509.DNSName("www.bb.com")]),
critical=False,
)
.sign(server_key, hashes.SHA256())
)
with open("server_bb_com.csr", "wb") as f:
f.write(csr.public_bytes(serialization.Encoding.PEM))

3b. The intermediate signs it

The intermediate reads only the CSR — never a private key — and issues the final leaf certificate. Critically, BasicConstraints(ca=False) here: a leaf cert must not be allowed to sign anything further. If a server cert could sign other certs, any compromised website could mint trusted certificates for arbitrary domains.

import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import load_pem_private_key

csr = x509.load_pem_x509_csr(open("server_bb_com.csr", "rb").read())
assert csr.is_signature_valid
intermediate_key = load_pem_private_key(open("intermediate_rsa.key", "rb").read(), password=None)
intermediate_cert = x509.load_pem_x509_certificate(open("intermediate.crt", "rb").read())
san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
builder = (
x509.CertificateBuilder()
.subject_name(csr.subject)
.issuer_name(intermediate_cert.subject)
.public_key(csr.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=90))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(
digital_signature=True, content_commitment=False, key_encipherment=True,
data_encipherment=False, key_agreement=False,
key_cert_sign=False, crl_sign=False,
encipher_only=False, decipher_only=False,
), critical=True)
.add_extension(san_ext.value, critical=False)
)
server_cert = builder.sign(private_key=intermediate_key, algorithm=hashes.SHA256())

A real server also bundles its own cert with the intermediate’s, since browsers need both to build the chain but already have the root pre-installed:

with open("server_bb_com_fullchain.crt", "wb") as f:
f.write(server_cert.public_bytes(serialization.Encoding.PEM))
f.write(intermediate_cert.public_bytes(serialization.Encoding.PEM))
$ openssl verify -CAfile root.crt -untrusted intermediate.crt server_bb_com.crt
server_bb_com.crt: OK

4. How the client verifies the server

This is the step that actually protects you. During the TLS handshake, the server sends its leaf cert plus the intermediate cert. The client already has the root pre-installed — that’s what “trusted root store” means. The client then:

  1. Checks the leaf’s signature against the intermediate’s public key
  2. Checks the intermediate’s signature against the root’s public key
  3. Checks every certificate’s validity window
  4. Checks the requested hostname against the leaf’s SAN list — not the CN
import datetime
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import padding

def verify_signature(child_cert, issuer_cert) -> bool:
try:
issuer_cert.public_key().verify(
child_cert.signature,
child_cert.tbs_certificate_bytes,
padding.PKCS1v15(),
child_cert.signature_hash_algorithm,
)
return True
except Exception:
return False

def check_hostname(cert, hostname: str) -> bool:
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
return hostname in san.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
return False

def verify_chain(leaf_cert, intermediate_cert, trusted_root_cert, hostname):
if not verify_signature(leaf_cert, intermediate_cert):
return False, "Leaf signature does not match intermediate's public key"
if not verify_signature(intermediate_cert, trusted_root_cert):
return False, "Intermediate signature does not match root's public key"
if not check_hostname(leaf_cert, hostname):
return False, f"Hostname {hostname} not found in leaf cert's SAN list"
return True, "Chain valid, trusted, and hostname matches"

Running this against our generated chain:

Connecting to bb.com   -> TRUSTED  -- Chain valid, trusted, and hostname matches
Connecting to evil.com -> REJECTED -- Hostname evil.com not found in leaf cert's SAN list

That second line is the whole point of the exercise. It is exactly the check that stops an attacker from taking a legitimately-issued certificate for one site and presenting it while impersonating another.

A valid signature only proves a CA issued the certificate. The hostname check is what proves it was issued for this site.

5. ECDHE: deriving a shared secret

Trust is now established. RSA and certificates are finished — they don’t get used again for the rest of the connection. Now the client and server need a symmetric key for AES, without ever sending that key across the network.

The trick: each side generates its own ephemeral EC key pair (fresh, one-time, thrown away after this session — the “E” in ECDHE). They exchange only their public keys. Each side then combines its own private key with the other side’s public key. Elliptic curve math guarantees both sides land on the exact same number — this is the Diffie-Hellman property.

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

server_ephemeral_private = ec.generate_private_key(ec.SECP384R1())
client_ephemeral_private = ec.generate_private_key(ec.SECP384R1())
server_ephemeral_public = server_ephemeral_private.public_key()
client_ephemeral_public = client_ephemeral_private.public_key()
# Only public keys cross the wire
server_public_bytes = server_ephemeral_public.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
client_public_bytes = client_ephemeral_public.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
# Server: own private key + client's public key
received_client_pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP384R1(), client_public_bytes)
server_shared_raw = server_ephemeral_private.exchange(ec.ECDH(), received_client_pub)
# Client: own private key + server's public key
received_server_pub = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP384R1(), server_public_bytes)
client_shared_raw = client_ephemeral_private.exchange(ec.ECDH(), received_server_pub)
assert server_shared_raw == client_shared_raw # they match, despite never being transmitted

def derive_aes_key(raw_shared_secret: bytes) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
info=b"bb.com TLS session key").derive(raw_shared_secret)

server_aes_key = derive_aes_key(server_shared_raw)
client_aes_key = derive_aes_key(client_shared_raw)
assert server_aes_key == client_aes_key

Output:

Raw ECDH shared secret matches on both sides: c9dd611238a294419dc1497bcacb1e2d ...
Derived AES-256 session key (both sides identical): 1607219d741fe59d02465b24a077fa0...

Note that the raw ECDH output isn’t used directly as the AES key — it’s passed through HKDF (a key derivation function) first. This is standard practice: it evens out any structure in the raw ECDH math, and it lets you derive multiple independent keys — for example, a separate key for each direction — from one shared secret.

Because a fresh ephemeral key pair is generated per session, this is also where TLS gets its forward secrecy: even if the server’s long-term RSA key is compromised years later, past sessions can’t be decrypted retroactively, because their ephemeral keys were never stored anywhere.

6. The encrypted message exchange

With an identical AES key on both sides, RSA and ECDHE are both done for good. Every subsequent message uses AES-GCM — fast, symmetric, and it authenticates each message as a side effect (the “GCM” tag is what detects tampering).

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

with open("session_aes_key.bin", "rb") as f:
session_key = f.read()
aesgcm = AESGCM(session_key)

def encrypt(plaintext: bytes) -> tuple[bytes, bytes]:
nonce = os.urandom(12) # unique per message, never reused with the same key
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
return nonce, ciphertext

def decrypt(nonce: bytes, ciphertext: bytes) -> bytes:
return aesgcm.decrypt(nonce, ciphertext, associated_data=None)

The client sends a request, the server responds, both directions using that one derived key:

Client -> Server (on the wire): 4204fe67966f959a42969365261b2fc9e5886739528f451c55e18eab3bcb72c9f8d5666a9a207caa9f096f69dd
Server decrypts to: GET /account-balance HTTP/1.1
Server -> Client (on the wire): 083fefe2ad52c7a08ffb84b937fb8c7caf9910551e890ac579cd6696b1cee3ca449d2f177f5e245b08b7055e37ec8d0029f2
Client decrypts to: HTTP/1.1 200 OK
Balance: $4213.00

And flipping a single bit in the ciphertext before decrypting demonstrates the integrity guarantee:

--- Tampering demo ---
Tampered message REJECTED: InvalidTag

That InvalidTag exception is AES-GCM’s authentication tag doing its job. Without it, an attacker could flip bits in the ciphertext and the server would silently decrypt to garbage instead of rejecting it outright — which is precisely the class of bug that made older, unauthenticated cipher modes so dangerous.

Putting it all together

Three stages, three algorithms, three very different lifespans:

  • Certificate chain — RSA + SHA-256, to prove server identity. Long-lived: roots last years, leaf certs around 90 days.
  • Key exchange — ECDHE on SECP384R1, to agree on a shared secret with forward secrecy. Lives for one session, then is discarded.
  • Data encryption — AES-256-GCM, to encrypt and authenticate every message. Same session.

This is, structurally, the same flow real TLS 1.3 runs. A cipher suite name like TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 is just those exact four pieces — key exchange, signature algorithm, symmetric cipher, hash function — spelled out in one string. Once you’ve read it that way, the name stops being noise.

Nothing in a real HTTPS connection is fundamentally different from what you just ran locally. It’s the same math — just with a CA your browser already trusts instead of one you generated yourself five minutes ago.

If you want to keep going: run each script in order, then open the generated files with openssl x509 -text -noout -in server_bb_com.crt and find every field discussed above sitting there in plain text. Try setting path_length=0 on the root and watch the intermediate refuse to issue. Try tampering with a byte of the leaf certificate and watch step 4 catch it.

That’s the fastest way to stop treating TLS as a black box — break it yourself, in a directory where nothing is at stake.

--

--