TLS/SSL & Secure Communications

Handshake, Certificates, mTLS, Certificate Pinning

Introduction

TLS (Transport Layer Security) is the protocol that secures the internet. Every HTTPS request, every secure email, every VPN tunnel uses TLS. Understanding TLS deeply, the handshake, certificates, and trust chains, is essential for securing any networked application. This lesson explains how TLS works, implements it in Python, generates self-signed certificates, and covers mutual TLS for service-to-service authentication.

TLS 1.3 Handshake

The TLS handshake establishes a secure channel before any application data is sent. TLS 1.3 reduced this to 1 round-trip (from 2 in TLS 1.2), significantly improving latency.

1
ClientHello
Client sends supported TLS versions, cipher suites, random bytes, and key share (ECDHE public key).
2
ServerHello
Server selects cipher suite, sends its random bytes and key share. Both sides compute the shared secret using ECDHE.
3
Server Certificate
Server sends its X.509 certificate (signed by a CA). Client verifies the certificate chain and hostname.
4
Certificate Verify
Server proves it owns the private key by signing the handshake transcript.
5
Finished
Both sides exchange Finished messages (MAC of the entire handshake) to confirm nothing was tampered with.
6
Application Data
Secure channel established. All data is now encrypted with the negotiated symmetric cipher (e.g., AES-256-GCM).

X.509 Certificates & Chain of Trust

A certificate is a digitally signed document that binds a public key to an identity. The trust chain proves legitimacy: your browser trusts a Root CA, which signed an Intermediate CA, which signed the server's certificate.

Root CA
e.g., DigiCert Global Root G2 (built into OS/browser)
↓ Signs
Intermediate CA
e.g., DigiCert TLS RSA SHA256 2020 CA1
↓ Signs
End-Entity Certificate
e.g., CN=api.example.com (your server)

Generating a Self-Signed Certificate

Install: pip install cryptography

# generate_cert.py, Generate a self-signed cert with Python
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timezone, timedelta
import ipaddress

# Generate RSA private key
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

# Build certificate
subject = issuer = x509.Name([
    x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
    x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ByteCode Solutions"),
    x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
])

cert = (
    x509.CertificateBuilder()
    .subject_name(subject)
    .issuer_name(issuer)
    .public_key(private_key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(datetime.now(timezone.utc))
    .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
    .add_extension(
        x509.BasicConstraints(ca=True, path_length=None),
        critical=True,
    )
    .add_extension(
        x509.SubjectAlternativeName([
            x509.DNSName("localhost"),
            x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
        ]),
        critical=False,
    )
    .sign(private_key, hashes.SHA256())
)

# Save key and certificate to PEM files
with open("server.key", "wb") as f:
    f.write(private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    ))

with open("server.crt", "wb") as f:
    f.write(cert.public_bytes(serialization.Encoding.PEM))

print(f"Generated: server.key, server.crt")
print(f"Subject:   {cert.subject.rfc4514_string()}")
print(f"Valid:     {cert.not_valid_before_utc} → {cert.not_valid_after_utc}")
print(f"Key size:  {private_key.key_size} bits")
Expected Output:
Generated: server.key, server.crt
Subject:   CN=localhost,O=ByteCode Solutions,C=US
Valid:     2024-01-15 00:00:00+00:00 → 2025-01-15 00:00:00+00:00
Key size:  2048 bits

Python TLS Server & Client

Prerequisite: Run generate_cert.py from the previous section first to generate server.crt and server.key in your working directory.

TLS Server

# tls_server.py, TLS echo server (requires server.key + server.crt)
import socket
import ssl

HOST = "127.0.0.1"
PORT = 9443

# Server SSL context, present server certificate
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("server.crt", "server.key")
context.minimum_version = ssl.TLSVersion.TLSv1_3   # Require TLS 1.3

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as raw:
    raw.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    raw.bind((HOST, PORT))
    raw.listen(5)
    print(f"TLS server listening on {HOST}:{PORT}")

    with context.wrap_socket(raw, server_side=True) as tls_server:
        conn, addr = tls_server.accept()
        with conn:
            print(f"TLS connection from {addr}")
            print(f"  Cipher: {conn.cipher()}")
            print(f"  TLS version: {conn.version()}")
            data = conn.recv(1024)
            conn.sendall(data)   # echo back
Expected Output:
TLS server listening on 127.0.0.1:9443
TLS connection from ('127.0.0.1', 42592)
  Cipher: ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
  TLS version: TLSv1.3

TLS Client

# tls_client.py, TLS client (trust the self-signed cert)
import socket
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE   # dev only: skip verification for self-signed cert
context.minimum_version = ssl.TLSVersion.TLSv1_2   # TLS 1.2+ (use TLSv1_3 in production)

with socket.create_connection(("127.0.0.1", 9443)) as raw:
    with context.wrap_socket(raw, server_hostname="localhost") as tls:
        print(f"Connected, TLS {tls.version()}, {tls.cipher()[0]}")
        tls.sendall(b"Hello over TLS!")
        response = tls.recv(1024)
        print(f"Echo: {response.decode()}")
Expected Output:
Connected, TLS TLSv1.3, TLS_AES_256_GCM_SHA384
Echo: Hello over TLS!

Mutual TLS (mTLS)

Standard TLS authenticates only the server. Mutual TLS (mTLS) requires both the client and server to present certificates. This is used in service meshes (Istio), Kubernetes, zero-trust networks, and B2B API authentication.

Generate Client Certificate

Install: pip install cryptography

# generate_client_cert.py, Generate a self-signed client cert for mTLS
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timezone, timedelta

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

subject = issuer = x509.Name([
    x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
    x509.NameAttribute(NameOID.ORGANIZATION_NAME, "ByteCode Solutions"),
    x509.NameAttribute(NameOID.COMMON_NAME, "service-a"),   # identifies the client
])

cert = (
    x509.CertificateBuilder()
    .subject_name(subject)
    .issuer_name(issuer)
    .public_key(private_key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(datetime.now(timezone.utc))
    .not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
    .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
    .sign(private_key, hashes.SHA256())
)

with open("client.key", "wb") as f:
    f.write(private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    ))

with open("client.crt", "wb") as f:
    f.write(cert.public_bytes(serialization.Encoding.PEM))

print("Generated: client.key, client.crt  (CN=service-a)")
Expected Output:
Generated: client.key, client.crt  (CN=service-a)

mTLS Server

# mtls_server.py, Mutual TLS server
import socket
import ssl

# Server requires client to present a certificate
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain("server.crt", "server.key")
server_context.verify_mode = ssl.CERT_REQUIRED    # ← require client cert
server_context.load_verify_locations("client.crt")  # trust this client CA

with socket.socket() as raw:
    raw.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    raw.bind(("127.0.0.1", 9444))
    raw.listen(5)
    with server_context.wrap_socket(raw, server_side=True) as tls_srv:
        conn, addr = tls_srv.accept()
        with conn:
            # Inspect client certificate
            client_cert = conn.getpeercert()
            cn = dict(x[0] for x in client_cert["subject"]).get("commonName")
            print(f"Authenticated client CN: {cn}")
            conn.sendall(b"mTLS handshake complete!")
Expected Output:
Authenticated client CN: service-a

mTLS Client

# mtls_client.py, Mutual TLS client
import socket
import ssl

client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.check_hostname = False
client_context.verify_mode = ssl.CERT_NONE   # dev only: skip verification for self-signed cert
client_context.load_cert_chain("client.crt", "client.key")  # present client cert

with socket.create_connection(("127.0.0.1", 9444)) as raw:
    with client_context.wrap_socket(raw, server_hostname="localhost") as tls:
        response = tls.recv(1024)
        print(f"Server: {response.decode()}")
Expected Output:
Server: mTLS handshake complete!

Key Takeaways

  • TLS 1.3 is the current standard, 1-RTT handshake, forward secrecy by default; disable TLS 1.0/1.1
  • Certificates bind identity to public key, signed by a CA in a chain of trust rooted at a trusted Root CA
  • ECDHE provides forward secrecy, even if the server key is later compromised, past sessions remain secure
  • Always verify certificates, check hostname and chain; never use ssl.CERT_NONE in production
  • mTLS authenticates both sides, essential for zero-trust service meshes and B2B API authentication
  • Self-signed certs are for testing only, production systems require CA-signed certificates (e.g., Let's Encrypt)
What's Next?

Lesson 10 moves from transport security to application security, authenticating users with sessions, JWTs, and OAuth2.

  • Session-based authentication, server-side sessions with Flask
  • JWT, creating, signing, and verifying tokens with PyJWT
  • OAuth2 flows, Authorization Code and Client Credentials
  • Token refresh patterns, maintaining long-lived authentication securely