Part 4 — Certificate-Authenticated Secure Channel

Building Your Own TLS, step by step

Author

Dmytro Huz

Published

April 26, 2026

1 About this article

This is Part 4 of the Rebuilding TLS from scratch series, where I build a TLS-like secure channel one feature at a time, in plain Python, so the mental models behind it become visible.

In this part we close the last big gap left by the previous installments: authentication. We add an X.509 certificate chain plus a per-session signature, and watch a man-in-the-middle attack stop working.

The walkthrough is meant for developers who have used HTTPS / TLS as consumers (web services, API clients, the browser padlock) but never opened the cover. No prior cryptography background is assumed beyond “there is such a thing as a public key and a private key”.

2 Where we are coming from

Across the previous parts of this series we built a small but real secure channel:

  • Part 1 — a plaintext client/server with a length-prefixed framing layer.
  • Part 2 — symmetric record protection: HMAC, then AEAD with sequence numbers.
  • Part 3 — replacing the pre-shared key with a real key exchange:
    • v1: classic finite-field Diffie–Hellman.
    • v2: X25519 (modern elliptic-curve DH).
    • v3: X25519 + HKDF to derive directional session keys for AES-256-GCM.

By the end of Part 3 v3, the pipeline looked like this:

handshake → shared secret → HKDF → session keys → AEAD records

Every connection got fresh keys. No hardcoded secrets. The on-the-wire format was already very close to TLS 1.3.

WarningThe gap that remained

Part 3 v3 still had no authentication. The handshake proved that someone shared a secret with us, but not who. An active attacker on the network — a man-in-the-middle (MITM) — could substitute their own public keys in both directions and silently sit between client and server.

This is what Part 4 fixes.

3 The MITM problem in one picture

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
sequenceDiagram
    participant C as Client
    participant M as Mallory (MITM)
    participant S as Server

    Note over C,S: No authentication — keys are swappable

    C->>M: ClientHello (client_pub)
    M->>S: ClientHello (mallory_pub_B)
    S->>M: ServerHello (server_pub)
    M->>C: ServerHello (mallory_pub_A)

    Note over C,M: shared_A = X25519(client_priv, mallory_pub_A)
    Note over M,S: shared_B = X25519(mallory_priv_B, server_pub)

    C->>M: AEAD encrypted with shared_A
    M->>S: re-encrypts with shared_B
    S-->>M: AEAD encrypted with shared_B
    M-->>C: re-encrypts with shared_A

The cryptography is correct; the identity binding is missing. Both sides end up with a perfectly fine secure channel — to the wrong peer.

The fix has two pieces:

  1. The server must prove who it is, using a certificate issued by an authority the client trusts.
  2. The server must prove that the public key it just sent really comes from it, by signing the exchanged handshake material with its private key.

The rest of this walkthrough builds those two pieces and wires them into the existing v3 pipeline.

4 The big picture

Before the details, here is the whole story in one image. Read it from left to right, top to bottom. Every following subsection just zooms into one box.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    subgraph WORLD["The trust world<br/>(set up in advance)<br/>"]
        direction LR
        ROOT["🏛️ Root CA<br/>self-signed<br/><b>preinstalled in client</b>"]
        INT["🏢 Intermediate CA<br/>signed by Root"]
        SRV["🖥️ Server cert<br/>'localhost'<br/>signed by Intermediate<br/>+ private key kept secret"]
        ROOT -->|signs| INT
        INT -->|signs| SRV
    end

    subgraph LIVE["The live connection (happens every session)"]
        direction LR
        DH["🔑 X25519<br/>key exchange"]
        AUTH["📜 Server sends:<br/>cert chain<br/>+ signature over keys"]
        VERIFY["✅ Client checks:<br/>1. chain → trusted Root<br/>2. signature → cert's pubkey"]
        SESS["🔐 Session keys<br/>(HKDF + AES-GCM)"]
        DH --> AUTH --> VERIFY --> SESS
    end

    classDef trustBox fill:#eef6ff,stroke:#0366d6,stroke-width:1px,color:#000
    class WORLD,LIVE trustBox

    SRV -. presented during handshake .-> AUTH
    ROOT -. used to verify chain .-> VERIFY
    

Three things to take away from this picture:

  1. Trust is set up in advance. The client already has the Root CA before it ever talks to the server. Nothing in the live connection adds new trust; it only uses trust that was already there.
  2. The server proves itself with two things. A certificate chain (says who it is) plus a fresh signature (says it really is here, right now).
  3. The session keys come last. Authentication happens first; only then do we agree on the keys that will encrypt the actual application data.

The next sections walk through each box.

5 Concepts you need first

5.1 0. Two completely different kinds of keys

Before anything else, the single most important distinction in this whole protocol: there are two completely separate kinds of keys, and they exist for different reasons. Mixing them up is the most common source of confusion.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    subgraph IDENTITY["🪪 IDENTITY keys (RSA, long-term)"]
        direction TB
        IDP["🔒 identity_private_key<br/>(server only, on disk, NEVER leaves)"]
        IDC["📜 identity_cert<br/>contains identity_public_key<br/>+ chain to a trusted CA"]
        IDP -. matches .-> IDC
    end

    subgraph EPHEMERAL["⚡ EPHEMERAL keys (X25519, per-connection)"]
        direction TB
        EPC["🔑 client_ephemeral_(priv,pub)<br/>generated by CLIENT, this session only"]
        EPS["🔑 server_ephemeral_(priv,pub)<br/>generated by SERVER, this session only"]
    end

    classDef idBox fill:#fff5e6,stroke:#cc7a00,stroke-width:1.5px,color:#000
    classDef epBox fill:#eaf5ff,stroke:#0366d6,stroke-width:1.5px,color:#000
    class IDENTITY idBox
    class EPHEMERAL epBox

🪪 Identity keys ⚡ Ephemeral keys
Algorithm RSA-2048 X25519
Lifetime years one connection
Owner only the server both client and server
Purpose prove WHO the server is agree on a SHARED SECRET for this session
On the wire sent inside the certificate chain raw public keys in ClientHello / ServerHello
Used for sign() / verify() Diffie–Hellman key agreement

The two worlds touch only in one place: the server uses its identity private key to sign the two ephemeral public keys. That single signature is what binds “you really are localhost” (identity) to “and this is the ephemeral key I am using right now” (session). Everything else stays neatly separated:

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    IDPRIV["🔒 identity_private_key<br/>(RSA)"] -->|"sign()"| SIG["✍️ CertificateVerify<br/>signature"]
    EPHC["⚡ client_ephemeral_public"] --> SIG
    EPHS["⚡ server_ephemeral_public"] --> SIG
    SIG --> WIRE["sent to client<br/>inside ServerAuth"]

    classDef idBox fill:#fff5e6,stroke:#cc7a00,stroke-width:1.5px,color:#000
    classDef epBox fill:#eaf5ff,stroke:#0366d6,stroke-width:1.5px,color:#000
    class IDPRIV idBox
    class EPHC,EPHS epBox

Keep this picture in mind: every diagram below uses the 🪪 orange colour for identity keys and ⚡ blue for ephemeral keys.

5.2 1. Public-key cryptography in one picture

A keypair is two linked values: a private_key you keep secret, and a public_key you give to everyone. They have a magic property: the private key can produce a signature over any bytes, and the public key can verify that signature — but cannot create one.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    A["📝 message<br/>'hello'"] --> SIGN
    PRIV["🔒 private key<br/>(secret)"] --> SIGN["sign()"]
    SIGN --> SIG["✍️ signature"]

    A2["📝 same message"] --> VERIFY
    SIG --> VERIFY["verify()"]
    PUB["🔓 public key<br/>(shared)"] --> VERIFY
    VERIFY --> OK{"✅ valid<br/>or<br/>❌ invalid"}

Two consequences we will rely on:

  • A valid signature proves the holder of the private key signed exactly these bytes.
  • If even one byte changes, the signature no longer verifies.

That is the entire trick behind everything that follows.

5.2.1 Under the hood: what sign() and verify() actually compute

The “sign” and “verify” boxes above are not magic — they are concrete operations you can describe in two lines. Almost every signature scheme used in practice (RSA-PSS, ECDSA, Ed25519, …) follows the same two-step recipe:

  1. Hash the message with a cryptographic hash like SHA-256 to get a short fixed-size digest (32 bytes for SHA-256, regardless of how big the message is).
  2. Apply the signature algorithm to that digest using either the private key (to produce a signature) or the public key (to verify one).

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    subgraph SIGN_SIDE["✍️ sign(private_key, message)"]
        direction TB
        M1["📝 message<br/>(any length)"]
        M1 --> H1["SHA-256<br/>(or any cryptographic hash)"]
        H1 --> DIG1["🔢 digest<br/>(32 bytes, fixed)"]
        DIG1 --> ALG1["signature algorithm<br/>(RSA-PSS / ECDSA / Ed25519 / …)<br/>using 🔒 private_key"]
        ALG1 --> SIG1["✍️ signature<br/>(opaque bytes)"]
    end

    subgraph VERIFY_SIDE["🔎 verify(public_key, signature, message)"]
        direction TB
        M2["📝 same message"]
        M2 --> H2["SHA-256<br/>(same hash function)"]
        H2 --> DIG2["🔢 digest<br/>(must equal the signer's)"]
        SIG2["✍️ received signature"] --> ALG2["signature algorithm verify<br/>using 🔓 public_key"]
        DIG2 --> ALG2
        ALG2 --> OK{"✅ valid<br/>or<br/>❌ invalid"}
    end

A few things this picture makes precise:

  • Why hash first? The signature algorithms have a fixed input size and are slow. Hashing collapses any-length input to a small digest, so the expensive operation runs on a constant 32 bytes.
  • Why is the signature bound to the message? Because the digest is. Change one byte of the message → completely different digest → signature no longer verifies.
  • Why can only the holder of private_key produce a valid signature? That’s the asymmetry built into RSA / ECDSA / Ed25519: given the public key alone, forging a signature is computationally infeasible.
  • What does verify() actually return? Either valid (the signature was produced by the matching private key over exactly this message) or invalid (anything else — wrong key, wrong message, tampered signature). It never tells you who signed; that knowledge has to come from elsewhere (e.g. a certificate).

We will reuse this exact picture in three different roles below:

Role What gets signed Whose private key signs Whose public key verifies
Issuing a certificate (§2) the cert body (tbsCertificate) the issuer (a CA) anyone who trusts the issuer
CertificateVerify during handshake (§7) the two ephemeral X25519 public keys the server’s identity key the client (after chain check)
Self-signed root (§3) the root’s own cert body the root itself the root itself (sanity check)

Same machinery, three different jobs.

5.3 2. From a public key to a certificate

A certificate is a small structured document that binds a public key to an identity and is signed by someone who vouches for that binding. The format used on the internet is X.509 — an old (1988) but still ubiquitous standard originally from the ITU-T directory specs, now profiled for the web by RFC 5280. Every TLS certificate you have ever seen — the padlock in your browser, the cert your server presents — is X.509.

A bare public key has no name attached. Anyone can generate one. So how do you know “this public key belongs to localhost”?

You ask someone you already trust to sign that statement. The signed statement is a certificate.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    BODY["📄 Cert body (tbsCertificate)<br/>━━━━━━━━━━━━━━━━<br/>subject: <b>localhost</b><br/>public_key: <b>0xAB12…</b><br/>valid_from / valid_to<br/>extensions"]
    ISSUER_PRIV["🔒 Issuer's<br/>private key"]
    BODY --> SIGN["sign()"]
    ISSUER_PRIV --> SIGN
    SIGN --> CERT["📜 X.509 Certificate<br/>━━━━━━━━━━━━━━━━<br/>cert body (above)<br/>+ ✍️ <b>issuer signature</b>"]

A certificate is just (body, signature). The body says what is being claimed; the signature says who is claiming it. To trust a certificate, you must already trust the issuer’s public key.

TipMental model

A certificate ≈ an ID card. The body is your photo and name; the signature is the government’s hologram. The card is only believable to people who already trust that government.

5.3.1 Under the hood: the actual structure of an X.509 certificate

X.509 stores a certificate as three top-level fields. The first field is called tbsCertificate — literally “to be signed” — because it is exactly the bytes the issuer signs.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    subgraph CERT["📜 X.509 Certificate (DER bytes)"]
        direction TB
        TBS["📄 <b>tbsCertificate</b> — &quot;the bytes that get signed&quot;<br/>━━━━━━━━━━━━━━━━━━━━<br/>version<br/>serialNumber<br/>signatureAlgorithm (e.g. sha256WithRSAEncryption)<br/>issuer (X.500 name)<br/>validity (notBefore, notAfter)<br/>subject (X.500 name)<br/><b>subjectPublicKeyInfo</b> ← the certified public key<br/>extensions (BasicConstraints, KeyUsage, SAN, SKI, AKI…)"]
        ALG["🔧 signatureAlgorithm<br/>(repeated, must match the one inside tbsCertificate)"]
        SIG["✍️ <b>signatureValue</b><br/>= sign(issuer_private_key, SHA-256(tbsCertificate))"]
    end

    classDef body fill:#f0f7ff,stroke:#0366d6,color:#000
    classDef sig fill:#fff0f0,stroke:#c00,color:#000
    class TBS body
    class SIG sig

Two things to notice:

  1. The public key being certified lives inside tbsCertificate — in the subjectPublicKeyInfo field. So when an issuer signs tbsCertificate, the signature covers the public key. Nobody can swap a different key into the certificate without invalidating the signature.
  2. signatureValue is computed as sign(issuer_private_key, SHA-256(tbsCertificate)). Verification is the mirror image: hash the tbsCertificate bytes again and check the signature with the issuer’s public key.

In Python with cryptography, the verification a CA verifier does for one link of the chain looks essentially like this:

# Conceptually:
issuer_public_key.verify(
    signature      = child_cert.signature,            # signatureValue
    data           = child_cert.tbs_certificate_bytes, # exact DER bytes
    padding        = PKCS1v15(),                      # for sha256WithRSAEncryption
    algorithm      = SHA256(),
)

If even one byte in tbs_certificate_bytes changes, the SHA-256 changes, and the signature fails to verify. That’s the integrity guarantee.

5.4 3. Self-signed roots: where trust starts

Trust has to start somewhere. A Root CA signs its own certificate — its issuer is itself. Why is this OK? Because clients don’t trust the root because of its signature; they trust it because a copy of it is already in their trust store, shipped with the OS or browser.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    ROOT["🏛️ Root CA cert<br/>━━━━━━━━━━━━<br/>subject: Root CA<br/>issuer: <b>Root CA</b> (itself)<br/>public_key: K_root"]
    PRIV_ROOT["🔒 Root private key"]
    ROOT --> SS["self-sign"]
    PRIV_ROOT --> SS
    SS --> ROOT_SIGNED["📜 self-signed Root cert"]
    ROOT_SIGNED ==> STORE["📦 Client trust store<br/>(preinstalled)"]

If a certificate is in the trust store, it is trusted. Period. That is what “trust anchor” means.

5.5 4. Intermediate CA: delegating signing power

In practice the root almost never signs end-entity certificates directly. Roots are precious — their private keys are kept offline in vaults. Instead, the root signs an Intermediate CA, and the intermediate does the day-to-day signing.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    PRIV_ROOT["🔒 Root private key<br/>(in a vault)"]
    INT_BODY["📄 Intermediate body<br/>subject: Intermediate CA<br/>public_key: K_int<br/>BasicConstraints: <b>ca=True</b>"]
    INT_BODY --> SIGN_R["sign()"]
    PRIV_ROOT --> SIGN_R
    SIGN_R --> INT["📜 Intermediate cert<br/>signed by Root"]

The ca=True flag in the body is what gives this certificate the right to sign other certificates. Without it, no verifier would accept its signatures.

5.6 5. Server certificate: the leaf

The intermediate CA now signs the actual server certificate. This one has ca=False (it cannot sign anything else) and lists the DNS names it covers.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    PRIV_INT["🔒 Intermediate private key"]
    SRV_BODY["📄 Server body<br/>subject: localhost<br/>public_key: K_srv<br/>SAN: <b>localhost</b><br/>BasicConstraints: <b>ca=False</b>"]
    SRV_BODY --> SIGN_I["sign()"]
    PRIV_INT --> SIGN_I
    SIGN_I --> SRV["📜 Server cert<br/>signed by Intermediate"]

Meanwhile the server’s private key never leaves the server. It is the only thing that can produce signatures verifiable by K_srv.

5.7 6. The full chain, end to end

Now stack the three together. Each arrow is one signature; the colour of the key tells you who held the pen.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    subgraph TRUSTED["📦 Client trust store"]
        ROOT["🏛️ Root cert<br/>K_root, self-signed"]
    end

    INT["🏢 Intermediate cert<br/>K_int<br/>signed by K_root 🔒"]
    SRV["🖥️ Server cert<br/>K_srv, SAN=localhost<br/>signed by K_int 🔒"]

    ROOT -->|"signature 1"| INT
    INT  -->|"signature 2"| SRV

    SRV -. "server holds<br/>matching 🔒 K_srv_priv<br/>(stays on server)" .- KEY["🔒 server private key"]

Verifying the chain on the client side is just checking each link in turn, starting from the leaf:

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    Q1["Server cert valid?<br/>signed by K_int? ✅"] -->
    Q2["Intermediate valid?<br/>signed by K_root? ✅"] -->
    Q3["K_root in trust store? ✅"] -->
    OK["🎉 chain trusted →<br/>now I trust K_srv"]

If any check fails — bad signature, expired cert, missing CA flag, hostname mismatch — the whole chain is rejected.

5.7.1 Under the hood: how a verifier finds the right parent

When the client receives a chain [server_cert, intermediate_cert] plus its trust store [root_cert], it has to figure out which certificate signed which. Two extensions make that possible:

  • SubjectKeyIdentifier (SKI) — a short fingerprint of the certificate’s own public key. Lives in every CA cert.
  • AuthorityKeyIdentifier (AKI) — the SKI of the issuer’s public key. Lives in every cert that is not a self-signed root.

The convention used by RFC 5280 (and by the cryptography library) is:

\[ \text{SKI} = \text{SHA-1}\bigl(\text{subjectPublicKeyInfo bytes (the BIT STRING contents)}\bigr) \]

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    SPKI["📦 subjectPublicKeyInfo<br/>(public key bytes inside the cert)"] --> H["SHA-1"]
    H --> SKI["🆔 SubjectKeyIdentifier<br/>e.g. 7c:a5:1e:9b:…"]

Chain matching then becomes a simple lookup: for each certificate, look at its AKI and find the cert in the chain whose SKI equals it.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart LR
    SRV["🖥️ Server cert<br/>SKI: <b>S_srv</b><br/>AKI: <b>S_int</b> ──┐"]
    INT["🏢 Intermediate cert<br/>SKI: <b>S_int</b> ◀──────────┘<br/>AKI: <b>S_root</b> ──┐"]
    ROOT["🏛️ Root cert<br/>SKI: <b>S_root</b> ◀──┘<br/>(self-signed: AKI = own SKI)"]

    SRV -->|AKI matches SKI| INT
    INT -->|AKI matches SKI| ROOT

Without these two extensions a verifier would have to compare full distinguished names, which works but is slower and fragile (two CAs can have the same name with different keys during key rollover). SKI/AKI make the match unambiguous and key-based.

NoteWhy SHA-1?

SHA-1 is broken for collision resistance, but SubjectKeyIdentifier only needs a stable identifier for lookup — not a security-critical hash. The real signature lives in signatureValue, computed with SHA-256.

5.8 7. The missing piece: a fresh signature

A verified chain only tells the client “this RSA identity public key (identity_public_key) is the legitimate identity of localhost. It does not prove that the peer on the other end of this TCP connection actually holds the matching identity_private_key. An attacker who copies a real cert off the internet has the chain too — they just don’t have the matching identity private key.

So the server must do one extra thing during the handshake: use its identity private key to sign something fresh that only it could sign right now — namely, the two ephemeral X25519 public keys that this specific session is using.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
sequenceDiagram
    participant C as Client
    participant S as Server (holds 🔒 identity_private_key)

    Note over C: generates ⚡ client_ephemeral_(priv, pub)
    C->>S: client_ephemeral_public (X25519)
    Note over S: generates ⚡ server_ephemeral_(priv, pub)
    S->>C: server_ephemeral_public (X25519)
    Note right of S: signature = sign(🔒 identity_private_key,<br/>   client_ephemeral_pub ‖ server_ephemeral_pub)
    S->>C: identity cert chain + CertificateVerify(signature)
    Note over C: 1. verify chain → trust identity_public_key<br/>2. verify(identity_public_key, signature,<br/>          client_ephemeral_pub ‖ server_ephemeral_pub)<br/>   ✅ peer really holds identity_private_key<br/>   ✅ AND signed THIS session's ephemeral keys

Why concatenate client_ephemeral_public ‖ server_ephemeral_public?

  • server_ephemeral_public is fresh (new X25519 keypair every connection) → an attacker can’t replay an old signature.
  • client_ephemeral_public ties the signature to this client’s contribution → an attacker can’t grab the server’s signature in flight and reuse it on a different connection.

The two checks together — chain and fresh signature — are what finally close the MITM gap from Part 3.

5.8.1 Under the hood: the CertificateVerify signature, concretely

We covered the generic sign() / verify() recipe back in §Public-key cryptography in one picture (hash → algorithm). Here we just plug in the concrete values used by the handshake. The algorithm choice is RSA-PSS with SHA-256 — the same scheme TLS 1.3 uses for RSA certificates.

Generic recipe CertificateVerify in this protocol
Message any bytes client_ephemeral_public ‖ server_ephemeral_public
Hash a cryptographic hash SHA-256 → 32-byte digest
Signature algorithm RSA-PSS / ECDSA / Ed25519 / … RSA-PSS (MGF1-SHA-256, max salt length)
Signer’s private key private_key 🔒 identity_private_key (RSA-2048, server-only, on disk)
Verifier’s public key public_key 🪪 identity_public_key (read from the already-verified identity cert)
Signature size depends on algorithm 256 bytes (for RSA-2048)

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    subgraph SIGN_SIDE["✍️ On the server (sign)"]
        direction TB
        M1["⚡ client_ephemeral_pub ‖ ⚡ server_ephemeral_pub"]
        M1 --> H1["SHA-256"]
        H1 --> DIG1["32-byte digest"]
        DIG1 --> RSA1["RSA-PSS sign<br/>with 🔒 identity_private_key"]
        RSA1 --> SIG1["✍️ signature (256 bytes)"]
    end

    subgraph VERIFY_SIDE["🔎 On the client (verify)"]
        direction TB
        M2["⚡ same two ephemeral pub keys<br/>(client already has both)"]
        M2 --> H2["SHA-256"]
        H2 --> DIG2["32-byte digest"]
        SIG2["✍️ received signature"] --> RSA2["RSA-PSS verify<br/>with 🪪 identity_public_key<br/>(from verified cert)"]
        DIG2 --> RSA2
        RSA2 --> OK{"✅ valid<br/>or<br/>❌ invalid"}
    end

What this specific instance buys us beyond the generic guarantees:

  • Only the holder of identity_private_key can produce a signature that verifies — that’s authentication of who.
  • The digest covers both ephemeral public keys, so the signature is uniquely tied to this one session — no replay across connections.
NoteReality check: what does TLS 1.3 actually sign?

In our toy protocol the “fresh” bytes are simply client_ephemeral_pub ‖ server_ephemeral_pub. Real TLS 1.3 signs a hash of the entire handshake transcript so far, which protects more fields (cipher choice, extensions, server name, etc.) but follows the same principle.

6 Architecture and files

The Part 4 implementation lives in part_4/implementation/:

File Purpose
certificate.py Build, sign and verify X.509 certificates and chains.
setup_certificates.py Generate root + intermediate + server certs to disk (run once).
handshake.py X25519 key exchange + ServerAuth (cert chain + signature).
key_schedule.py HKDF session-key derivation (carried over from Part 3 v3).
record_protection.py AES-GCM record layer (carried over from Part 3 v3).
server_v4.py Runnable server.
client_v4.py Runnable client.

The full authenticated handshake looks like this:

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
sequenceDiagram
    autonumber
    participant C as Client
    participant S as Server

    Note over C: trusted_root in store
    Note over S: 🪪 server_identity_private_key<br/>+ identity cert chain on disk

    C->>S: ClientHello { ⚡ client_ephemeral_public }
    S->>C: ServerHello { ⚡ server_ephemeral_public }
    S->>C: ServerAuth { server_identity_cert,<br/>intermediate_cert,<br/>CertificateVerify(signature) }

    Note right of C: 1) verify identity cert chain<br/>   to trusted_root<br/>2) verify signature over<br/>   (client_eph_pub ‖ server_eph_pub)<br/>   using server_identity_cert.public_key
    Note over C,S: shared_secret = X25519(eph_priv, peer_eph_pub)<br/>HKDF → client_write_key, server_write_key

    C->>S: AEAD record (request)
    S-->>C: AEAD record (response)

Phase 1 is the new authenticated handshake. Phase 2 is unchanged from Part 3 v3.

7 Building it step by step

7.1 Step 1 — Names and extensions

A certificate’s subject and issuer are both X.509 Names: an ordered sequence of attributes (country, organization, common name…). We wrap that in a small data class so the shape is obvious at every call site.

@dataclass(frozen=True)
class CertificateName:
    country_name: str
    state_or_province_name: str
    locality_name: str
    organization_name: str
    common_name: str

    @property
    def certificate_name(self) -> x509.Name:
        return x509.Name([
            x509.NameAttribute(NameOID.COUNTRY_NAME, self.country_name),
            x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, self.state_or_province_name),
            x509.NameAttribute(NameOID.LOCALITY_NAME, self.locality_name),
            x509.NameAttribute(NameOID.ORGANIZATION_NAME, self.organization_name),
            x509.NameAttribute(NameOID.COMMON_NAME, self.common_name),
        ])

Two helpers in certificate.py produce the extensions appropriate for each role in the chain.

7.1.1 CA extensions

def ca_extensions(public_key, issuer_certificate=None, path_length=None):
    extensions = [
        Extension(
            oid=x509.ExtensionOID.BASIC_CONSTRAINTS,
            critical=True,
            value=x509.BasicConstraints(ca=True, path_length=path_length),
        ),
        Extension(
            oid=x509.ExtensionOID.KEY_USAGE,
            critical=True,
            value=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,
            ),
        ),
        Extension(
            oid=x509.ExtensionOID.SUBJECT_KEY_IDENTIFIER,
            critical=False,
            value=x509.SubjectKeyIdentifier.from_public_key(public_key),
        ),
    ]

    if issuer_certificate is not None:
        extensions.append(Extension(
            oid=x509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
            critical=False,
            value=x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
                issuer_certificate.extensions
                    .get_extension_for_class(x509.SubjectKeyIdentifier).value
            ),
        ))

    return extensions

What each extension is doing:

  • BasicConstraints(ca=True) — this certificate is allowed to issue other certificates. path_length caps how many further CAs may appear below it (root uses 1, intermediate uses 0).
  • KeyUsage(key_cert_sign, crl_sign) — the only thing this CA key may do is sign certificates and revocation lists. It deliberately cannot be used for TLS handshake signing or encryption.
  • SubjectKeyIdentifier — names this CA’s key, so children can point back to it.
  • AuthorityKeyIdentifier — present on every cert except the root; copies the issuer’s SKI into this cert’s AKI. This is the “next pointer” of the chain.

7.1.2 Server (end-entity) extensions

def server_extensions(public_key, dns_names, issuer_certificate):
    return [
        Extension(  # not a CA
            oid=x509.ExtensionOID.BASIC_CONSTRAINTS,
            critical=True,
            value=x509.BasicConstraints(ca=False, path_length=None),
        ),
        Extension(
            oid=x509.ExtensionOID.KEY_USAGE,
            critical=True,
            value=x509.KeyUsage(
                digital_signature=True, key_encipherment=True,
                # everything else False
                content_commitment=False, data_encipherment=False,
                key_agreement=False, key_cert_sign=False, crl_sign=False,
                encipher_only=False, decipher_only=False,
            ),
        ),
        Extension(  # this is a TLS server certificate
            oid=x509.ExtensionOID.EXTENDED_KEY_USAGE,
            critical=False,
            value=x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
        ),
        Extension(  # the hostnames this cert is valid for
            oid=x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME,
            critical=False,
            value=x509.SubjectAlternativeName([DNSName(n) for n in dns_names]),
        ),
        Extension(
            oid=x509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
            critical=False,
            value=x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
                issuer_certificate.extensions
                    .get_extension_for_class(x509.SubjectKeyIdentifier).value
            ),
        ),
        Extension(
            oid=x509.ExtensionOID.SUBJECT_KEY_IDENTIFIER,
            critical=False,
            value=x509.SubjectKeyIdentifier.from_public_key(public_key),
        ),
    ]

The differences from a CA cert are:

  • ca=False — this cert may not sign other certs.
  • digital_signature and key_encipherment instead of key_cert_sign.
  • EXTENDED_KEY_USAGE = SERVER_AUTH — it is a TLS server cert.
  • SubjectAlternativeName lists the DNS names the cert is valid for. Modern verifiers ignore the legacy commonName and look here.

7.2 Step 2 — Building and signing certificates

A certificate is built by CertificateBuilder, then signed with the issuer’s private key:

def issue_certificate(public_key, subject, issuer,
                     issuer_private_key, extensions,
                     validity_to, validity_from=None):
    builder = (
        x509.CertificateBuilder()
        .subject_name(subject.certificate_name)
        .issuer_name(issuer.certificate_name)
        .public_key(public_key)
        .serial_number(x509.random_serial_number())
        .not_valid_before(validity_from or datetime.now(timezone.utc))
        .not_valid_after(validity_to)
    )
    for extension in extensions:
        builder = builder.add_extension(extension.value, critical=extension.critical)
    return builder.sign(private_key=issuer_private_key, algorithm=hashes.SHA256())

The signature covers everything in the certificate body (the tbsCertificate) using SHA-256 + RSA. After this, the certificate is immutable: any byte-level change invalidates the signature.

NoteSelf-signed roots

For the root CA, subject == issuer and the issuer key is the root’s own private key. It signs its own certificate. That self-signature is not what makes the root trusted — being in the trust store is. The signature is only checked for integrity.

7.3 Step 3 — Generating the chain on disk

setup_certificates.py generates a full three-level chain and writes PEM files into certs/. A trimmed version:

# Root CA — self-signed
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
root_cert = certificate.issue_certificate(
    public_key=root_key.public_key(),
    subject=root_name, issuer=root_name,
    issuer_private_key=root_key,            # signs itself
    extensions=certificate.ca_extensions(root_key.public_key(), path_length=1),
    validity_from=now, validity_to=now + timedelta(days=365 * 10),
)

# Intermediate CA — signed by the root
intermediate_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
intermediate_cert = certificate.issue_certificate(
    public_key=intermediate_key.public_key(),
    subject=intermediate_name, issuer=root_name,
    issuer_private_key=root_key,            # ROOT signs the intermediate
    extensions=certificate.ca_extensions(
        intermediate_key.public_key(),
        issuer_certificate=root_cert,       # so AKI points at root SKI
        path_length=0,
    ),
    validity_from=now, validity_to=now + timedelta(days=365 * 5),
)

# Server (end-entity) — signed by the intermediate
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_cert = certificate.issue_certificate(
    public_key=server_key.public_key(),
    subject=server_name, issuer=intermediate_name,
    issuer_private_key=intermediate_key,    # INTERMEDIATE signs the server
    extensions=certificate.server_extensions(
        server_key.public_key(),
        dns_names=[DNS_NAME],
        issuer_certificate=intermediate_cert,
    ),
    validity_from=now, validity_to=now + timedelta(days=365 * 2),
)

Notice who holds which private key at each step — the issuer signs with its own private key; the certified party only contributes a public key (and stores its private key separately).

The script then performs a sanity-check verification before writing files, then saves four files into part_4/implementation/certs/:

File Held by
root_cert.pem Client (trust anchor)
intermediate_cert.pem Server (sent during handshake)
server_cert.pem Server (sent during handshake)
server_key.pem Server (kept secret)

7.4 Step 4 — Verifying a chain

Verification is delegated to the modern verifier in cryptography:

def verify_server_certificate(root_certificate, intermediate_certificates,
                              server_certificate, dns_name,
                              validation_time=None):
    store = Store([root_certificate])
    builder = PolicyBuilder().store(store)
    if validation_time is not None:
        builder = builder.time(validation_time)
    verifier = builder.build_server_verifier(DNSName(dns_name))
    return verifier.verify(server_certificate, intermediate_certificates)

What verifier.verify(...) does internally:

  1. Chain building: starting from the leaf, walk up the chain using AKI → SKI matches and subject/issuer name matches until a certificate in the trust Store is reached.
  2. Signature checks: for every link, verify issuer_pubkey.verify(child.signature, child.tbs_certificate_bytes, …).
  3. Validity window: every cert must be currently valid (or valid at validation_time if pinned).
  4. BasicConstraints: every non-leaf cert must have ca=True.
  5. KeyUsage: CAs must have key_cert_sign.
  6. ExtendedKeyUsage: leaf must allow SERVER_AUTH.
  7. DNS name match: the requested DNSName must appear in the leaf’s SAN.

If anything fails it raises an exception. On success it returns the verified chain (leaf → … → root) as a list.

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
flowchart TB
    L["Server cert<br/>SAN: localhost<br/>AKI = X"] -->|signed by| I["Intermediate<br/>SKI = X<br/>AKI = Y"]
    I -->|signed by| R["Root<br/>SKI = Y<br/>(in trust store)"]

    classDef anchor fill:#dff,stroke:#06c,stroke-width:2px
    class R anchor

7.5 Step 5 — The authenticated handshake

The handshake builds on Part 3 v3, adding one new server-to-client message called ServerAuth.

7.5.1 Message tags

TAG_X25519_PUBLIC      = 0x0010   # ephemeral X25519 public key (per session)
TAG_CERTIFICATE        = 0x0020   # one DER-encoded X.509 identity certificate
TAG_CERTIFICATE_VERIFY = 0x0021   # RSA-PSS signature over
                                  #   (client_ephemeral_pub || server_ephemeral_pub)
                                  # made with the server's identity private key

A ServerAuth record contains: the server’s identity cert (DER), zero or more intermediates (DER), and finally one CertificateVerify signature.

7.5.2 Signing on the server

The server uses its identity private key (RSA, long-term, on disk) to sign the concatenation of the two ephemeral X25519 public keys for this session.

def _sign_ephemeral_pubkeys_with_identity(
    identity_private_key,           # 🔒 RSA, long-term, server-only
    client_ephemeral_public_bytes,  # ⚡ X25519, this session
    server_ephemeral_public_bytes,  # ⚡ X25519, this session
):
    data = client_ephemeral_public_bytes + server_ephemeral_public_bytes
    return identity_private_key.sign(
        data,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH,
        ),
        hashes.SHA256(),
    )

Two important properties:

  • The signed bytes are both ephemeral public keys, in a fixed order. The client knows the same two values, so it can independently rebuild the message and verify.
  • The X25519 public keys are ephemeral — generated freshly per connection. So the signature is bound to this session and cannot be replayed.

7.5.3 Verifying on the client

def _verify_ephemeral_pubkeys_with_identity(
    identity_public_key,            # 🩪 from already-verified identity cert
    signature,
    client_ephemeral_public_bytes,  # ⚡
    server_ephemeral_public_bytes,  # ⚡
):
    data = client_ephemeral_public_bytes + server_ephemeral_public_bytes
    identity_public_key.verify(
        signature,
        data,
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.MAX_LENGTH),
        hashes.SHA256(),
    )

The identity_public_key here is taken from the already-verified identity certificate. That ordering matters: never use the public key from a certificate before the chain has been validated.

7.5.4 Putting it together — client side

def client_handshake(sock, trusted_root, dns_name):
    # 1) Generate the client's EPHEMERAL keypair, send it, receive the
    #    server's EPHEMERAL public key.
    client_ephemeral_private = X25519PrivateKey.generate()
    client_ephemeral_public_bytes = (
        client_ephemeral_private.public_key()
            .public_bytes(Encoding.Raw, PublicFormat.Raw)
    )
    send_record(sock, encode_message(
        [(TAG_X25519_PUBLIC, client_ephemeral_public_bytes)]
    ))

    server_ephemeral_public_bytes = _read_field(
        recv_record(sock), TAG_X25519_PUBLIC
    )
    server_ephemeral_public = X25519PublicKey.from_public_bytes(
        server_ephemeral_public_bytes
    )

    # 2) Receive ServerAuth: identity certificate chain + CertificateVerify.
    auth_fields = decode_message(recv_record(sock))
    cert_der_list = [v for t, v in auth_fields if t == TAG_CERTIFICATE]
    certificate_verify_signature = next(
        v for t, v in auth_fields if t == TAG_CERTIFICATE_VERIFY
    )

    server_identity_cert = x509.load_der_x509_certificate(cert_der_list[0])
    intermediate_certs = [
        x509.load_der_x509_certificate(d) for d in cert_der_list[1:]
    ]

    # 3) Verify identity chain FIRST, then the CertificateVerify signature.
    certificate.verify_server_certificate(
        root_certificate=trusted_root,
        intermediate_certificates=intermediate_certs,
        server_certificate=server_identity_cert,
        dns_name=dns_name,
    )
    server_identity_public_key = server_identity_cert.public_key()
    _verify_ephemeral_pubkeys_with_identity(
        server_identity_public_key,
        certificate_verify_signature,
        client_ephemeral_public_bytes,
        server_ephemeral_public_bytes,
    )

    # 4) Derive session keys from the EPHEMERAL X25519 shared secret.
    shared_secret = client_ephemeral_private.exchange(server_ephemeral_public)
    return derive_session_keys(shared_secret)

7.5.5 Putting it together — server side

def server_handshake(sock,
                    server_identity_private_key,  # 🔒 RSA, long-term
                    server_identity_cert,         # 📜 X.509
                    intermediate_certs):
    # 1) Generate the server's EPHEMERAL keypair and exchange Hellos.
    server_ephemeral_private = X25519PrivateKey.generate()
    server_ephemeral_public_bytes = (
        server_ephemeral_private.public_key()
            .public_bytes(Encoding.Raw, PublicFormat.Raw)
    )

    client_ephemeral_public_bytes = _read_field(
        recv_record(sock), TAG_X25519_PUBLIC
    )
    client_ephemeral_public = X25519PublicKey.from_public_bytes(
        client_ephemeral_public_bytes
    )

    send_record(sock, encode_message(
        [(TAG_X25519_PUBLIC, server_ephemeral_public_bytes)]
    ))

    # 2) Sign (client_ephemeral_pub || server_ephemeral_pub) with the
    #    long-term IDENTITY key, then send the chain + signature.
    certificate_verify_signature = _sign_ephemeral_pubkeys_with_identity(
        server_identity_private_key,
        client_ephemeral_public_bytes,
        server_ephemeral_public_bytes,
    )

    auth_fields  = [(TAG_CERTIFICATE,
                     server_identity_cert.public_bytes(Encoding.DER))]
    auth_fields += [(TAG_CERTIFICATE, c.public_bytes(Encoding.DER))
                    for c in intermediate_certs]
    auth_fields += [(TAG_CERTIFICATE_VERIFY, certificate_verify_signature)]
    send_record(sock, encode_message(auth_fields))

    # 3) Derive session keys from the EPHEMERAL X25519 shared secret.
    shared_secret = server_ephemeral_private.exchange(client_ephemeral_public)
    return derive_session_keys(shared_secret)

7.6 Step 6 — Key schedule and record layer (recap)

Nothing here changes from Part 3 v3.

def derive_session_keys(shared_secret):
    client_write_key = HKDF(
        algorithm=hashes.SHA256(), length=32,
        salt=None, info=b"part4 client write key",
    ).derive(shared_secret)

    server_write_key = HKDF(
        algorithm=hashes.SHA256(), length=32,
        salt=None, info=b"part4 server write key",
    ).derive(shared_secret)

    return client_write_key, server_write_key

The record layer uses the per-direction keys with AES-256-GCM and a sequence number bound into the AAD:

+-------------+--------------+-------------------------------+
| seq (8 B)   | nonce (12 B) | ciphertext_and_tag (N+16 B)   |
+-------------+--------------+-------------------------------+

Why nothing changed: authentication only fixes who you are talking to. The question of how data is protected once you are talking was already solved in Part 2 / Part 3 v3.

7.7 Step 7 — Server and client wiring

server_v4.py loads the long-term IDENTITY material, runs the authenticated handshake, and then exchanges one application record:

# Load the server's long-term IDENTITY (RSA private key + cert chain).
server_identity_private_key, server_identity_cert, intermediate_certs = (
    load_server_identity()
)

with socket.socket(...) as server:
    conn, addr = server.accept()

    # The handshake itself generates the per-connection EPHEMERAL
    # X25519 keypair internally and returns the derived session keys.
    client_write_key, server_write_key = server_handshake(
        conn,
        server_identity_private_key=server_identity_private_key,
        server_identity_cert=server_identity_cert,
        intermediate_certs=intermediate_certs,
    )

    raw_request = recv_record(conn)
    request = unprotect_record(client_write_key, recv_seq, raw_request)
    # ... build response ...
    send_record(conn, protect_record(server_write_key, send_seq, response))

client_v4.py is symmetric: it loads the trusted root, runs the authenticated handshake, then sends an HTTP-style request inside one AEAD record. The client never owns an identity key — it only trusts one (transitively, via trusted_root):

trusted_root = load_trusted_root()

with socket.socket(...) as client:
    client.connect((HOST, PORT))
    client_write_key, server_write_key = client_handshake(
        client,
        trusted_root=trusted_root,
        dns_name=DNS_NAME,
    )

    send_record(client, protect_record(client_write_key, send_seq, request))
    raw_response = recv_record(client)
    response = unprotect_record(server_write_key, recv_seq, raw_response)

8 Running the example

From part_4/implementation/:

# 1. Generate the certificate chain (only needed once).
python setup_certificates.py

# 2. Start the server.
python server_v4.py

# 3. In another terminal, run the client.
python client_v4.py

Expected output highlights on the client:

Loaded trusted root: <Name(...,CN=Root CA)>
[handshake] Client: starting authenticated handshake
  Generated EPHEMERAL X25519 keypair (this session only)
  -> Sent ClientHello
  <- Received ServerHello
  <- Received ServerAuth
  Verifying IDENTITY certificate chain for 'localhost'...
  Identity certificate chain: VALID
  Verifying CertificateVerify (binds identity → this session)...
  CertificateVerify: VALID — peer is the real 'localhost'
  X25519 shared secret: ...
  [key_schedule] Derived session keys: ...
[handshake] Client: handshake complete — authenticated session keys ready

9 Why this defeats the MITM

Replay the attack from Section 3 against the Part 4 protocol:

%%{init: {'theme':'base', 'themeVariables': {'background':'#ffffff','primaryColor':'#eef6ff','primaryBorderColor':'#0366d6','primaryTextColor':'#000','lineColor':'#555','clusterBkg':'#fafbfc','clusterBorder':'#888','tertiaryColor':'#ffffff','tertiaryBorderColor':'#888','tertiaryTextColor':'#000','noteBkgColor':'#fff8c5','noteTextColor':'#000','noteBorderColor':'#b5a000','actorBkg':'#eef6ff','actorBorder':'#0366d6','actorTextColor':'#000','signalColor':'#333','signalTextColor':'#000','sequenceNumberColor':'#fff'}}}%%
sequenceDiagram
    participant C as Client
    participant M as Mallory
    participant S as Server

    C->>M: ClientHello (⚡ client_eph_pub)
    M->>S: ClientHello (⚡ mallory_eph_pub_B)
    S->>M: ServerHello (⚡ server_eph_pub) + ServerAuth { 📜 server_identity_cert,<br/>sig over (mallory_eph_pub_B ‖ server_eph_pub) }
    M->>C: ServerHello (⚡ mallory_eph_pub_A) + ServerAuth { ??? , ??? }
    Note over C: verify identity chain → must end at trusted root<br/>verify sig over (client_eph_pub ‖ mallory_eph_pub_A)<br/>using server_identity_cert.public_key
    Note over C: ❌ Mallory cannot produce that signature<br/>without the server's IDENTITY private key

Mallory has two impossible tasks:

  1. Provide a valid identity certificate chain for the server’s name. Without compromising a CA, she cannot.
  2. Forge a CertificateVerify signature over (client_eph_pub ‖ mallory_eph_pub_A) using the server’s identity private key. The server only signed (mallory_eph_pub_B ‖ server_eph_pub), and Mallory does not have the server’s identity private key to re-sign new ephemeral keys.

Either she gives up impersonating the server, or the verification fails on the client and the connection is aborted before any application data is sent.

TipThe two checks are both required

If you only check the certificate chain, an attacker who recorded a real server’s ServerAuth once could replay it. The signature over the fresh ephemeral keys is what makes each session unforgeable.

If you only check the signature, you are trusting an arbitrary public key. The certificate chain is what binds that public key to a name you actually trust.

10 What is still simplified

Compared to real TLS 1.3 we deliberately leave several things out:

  • Only server authentication. No client certificates / mutual TLS.
  • Signed bytes are just client_ephemeral_pub ‖ server_ephemeral_pub. Real TLS signs a hash of the entire handshake transcript, which prevents a wider class of downgrade attacks.
  • No cipher-suite negotiation. AES-256-GCM and X25519 are hardcoded.
  • No revocation checking (CRL / OCSP). A compromised intermediate would remain trusted until expiry.
  • No session resumption, no 0-RTT, no extensions, no HelloRetryRequest.
  • PEM files on disk instead of a real PKI deployment.

Those simplifications keep every line of code readable while preserving the core security property the previous parts were missing.

11 Summary

  • Parts 1–3 built confidentiality and integrity, but not identity.
  • Without identity, key exchange alone cannot stop a man-in-the-middle.
  • Part 4 introduces an X.509 certificate chain (root → intermediate → server) and a per-session signature over the exchanged ephemeral keys.
  • The client’s verification is chain first, signature second, using the public key from the verified certificate.
  • The record layer and key schedule are unchanged from Part 3 v3 — only the handshake gained authentication.

After Part 4 the protocol has all the essential pieces of a modern TLS 1.3 handshake, in a few hundred lines of Python you can read end-to-end.

12 References

  • RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and CRL Profile.
  • RFC 5869 — HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
  • RFC 7748 — Elliptic Curves for Security (X25519).
  • RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3.
  • cryptography library — X.509 reference