Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TLS from Scratch

Build TLS from its cryptographic primitives. Each lesson introduces one concept with real-life analogies, shell commands, ASCII diagrams, and hands-on Rust exercises.

You’ll go from hashing a file to building an encrypted, authenticated communication channel — then use real TLS libraries and build production tools.

Course overview

Phase 1: Cryptographic Building Blocks
  Hashing → Encryption → Signatures → Key Exchange
  + Projects: TOTP Authenticator, Signed Git Commits

Phase 2: Putting Primitives Together
  Key Derivation → Password KDFs → Certificates → Cert Generation
  + Projects: Certificate Inspector, Password Manager Vault

Phase 3: Build a Mini-TLS
  Encrypted Echo → Authenticated Echo → mTLS → Replay Defense → Handshake Deep Dive
  + Project: Encrypted File Transfer

Phase 4: Real TLS
  tokio-rustls → HTTPS Client
  + Projects: HTTPS Server, TLS Scanner

Phase 5: Capstone Projects
  Certificate Authority, mTLS Service Mesh, TLS Proxy, Intercepting Proxy

What you’ll build

Lessons (16 total):

  • SHA-256 file hasher
  • ChaCha20-Poly1305 encryption with tamper detection
  • Ed25519 digital signatures
  • X25519 Diffie-Hellman key exchange
  • HKDF key derivation + password-based KDFs (Argon2)
  • X.509 certificate parsing + generation with rcgen
  • Encrypted echo server (your own mini-TLS)
  • Authenticated echo server, mutual TLS, replay defense
  • TLS 1.3 handshake deep dive
  • Real TLS with tokio-rustls, HTTPS client

Projects (11 total):

  • TOTP authenticator (Google Authenticator clone)
  • Signed git commits
  • Certificate inspector (check any website’s cert chain)
  • Password manager vault
  • Encrypted file transfer (mini scp)
  • HTTPS server
  • TLS scanner (probe server configurations)
  • Certificate authority
  • mTLS service mesh
  • TLS termination proxy
  • HTTPS intercepting proxy (mini mitmproxy)

Prerequisites

  • Rust fundamentals (ownership, traits, generics)
  • Basic networking (TCP/UDP)

Source code

git clone https://github.com/its-saeed/learn-by-building.git
cd learn-by-building
cargo run -p tls --bin 1-hash -- --file-path Cargo.toml

Lesson 0: Cryptography Fundamentals

The Story of Alice’s Bookstore

Alice runs a small online bookstore from her apartment. She sells rare books and ships worldwide. Her customers type their credit card numbers into her website, send her messages, and download digital books.

One day, her friend Bob — a security researcher — sits down with her at a coffee shop.

“Alice, your website sends everything in plaintext. Anyone on this Wi-Fi can see your customers’ credit cards.”

“What? How?”

“Let me show you the problems — and how cryptography solves each one.”

This is where our story begins. Each lesson solves one of Alice’s problems. By the end, her bookstore will be fully secure.

Before writing any code, you need to understand the vocabulary and core concepts. Every lesson that follows builds on these ideas.

Real-life analogy: sending a secret letter

Imagine you need to send a secret letter across town. Every cryptographic concept maps to a part of this scenario:

┌──────────────────────────────────────────────────────────────┐
│  The Secret Letter Problem                                   │
│                                                              │
│  You (Alice) want to send a letter to Bob.                   │
│  The mail carrier (the network) might read it.               │
│                                                              │
│  Confidentiality → put the letter in a locked box            │
│  Integrity       → seal the box with tamper-evident tape     │
│  Authentication  → stamp it with your wax seal               │
│                                                              │
│  Key             → the key to the locked box                 │
│  Hash            → a fingerprint of the letter               │
│  Signature       → your wax seal (only you have the ring)    │
│  Nonce           → a unique serial number on each box        │
│  Certificate     → a passport proving you are Alice          │
│                                                              │
│  Without these:                                              │
│    Anyone can read the letter (no confidentiality)           │
│    Anyone can change the letter (no integrity)               │
│    Anyone can pretend to be you (no authentication)          │
└──────────────────────────────────────────────────────────────┘

The three goals of cryptography

1. Confidentiality

Only the intended recipient can read the message. Everyone else sees random noise.

Alice writes: "meet at 3pm"
Alice encrypts: "meet at 3pm" → "x7#kQ!9pL@"
Eve intercepts: "x7#kQ!9pL@" (meaningless)
Bob decrypts: "x7#kQ!9pL@" → "meet at 3pm"

Without confidentiality: anyone on the network (ISP, router, attacker on the same Wi-Fi) can read your emails, passwords, bank transfers, medical records.

2. Integrity

The message hasn’t been modified in transit. If anyone changes even one bit, the recipient detects it.

Alice sends: "transfer $100 to Bob"
Eve intercepts, modifies: "transfer $999 to Eve"
Bob receives it — but integrity check FAILS → message rejected

Without integrity: an attacker can silently alter messages. Change a dollar amount, modify a software update to include malware, alter DNS responses to redirect traffic.

3. Authentication

You know who you’re talking to. The sender is who they claim to be.

Alice receives a message claiming to be from her bank.
Authentication check: is this really from the bank, or from an attacker pretending to be the bank?

Without authentication: phishing, man-in-the-middle attacks, impersonation. An attacker sets up a fake bank website — without authentication, your browser can’t tell the difference.

Core terminology

Plaintext and ciphertext

  • Plaintext: the original, readable data. Doesn’t have to be text — could be a file, image, or any bytes.
  • Ciphertext: the encrypted, unreadable version. Looks like random bytes. Same length as plaintext (roughly).
  • Encryption: plaintext → ciphertext (using a key)
  • Decryption: ciphertext → plaintext (using a key)
plaintext: "hello world"
     │
     ▼ encrypt(key)
ciphertext: 0x7a3f8b2e1c...
     │
     ▼ decrypt(key)
plaintext: "hello world"

Keys

A key is a secret value that controls encryption and decryption. Without the key, decryption is computationally impossible.

  • Symmetric key: one key for both encryption and decryption. Both sides must share the same key.
  • Asymmetric key pair: two keys — a public key (shared openly) and a private key (kept secret). What one encrypts, only the other can decrypt.
Symmetric:
  Alice and Bob both have key K
  Alice: encrypt(K, plaintext) → ciphertext
  Bob:   decrypt(K, ciphertext) → plaintext

Asymmetric:
  Bob has: public key (shared) + private key (secret)
  Alice: encrypt(Bob_public, plaintext) → ciphertext
  Bob:   decrypt(Bob_private, ciphertext) → plaintext

Cipher

An algorithm that performs encryption and decryption. Examples:

  • AES (Advanced Encryption Standard): symmetric, block cipher
  • ChaCha20: symmetric, stream cipher
  • RSA: asymmetric

The cipher is public — security comes from the key, not from keeping the algorithm secret. This is Kerckhoffs’s principle: a system should be secure even if everything about it is public knowledge except the key.

Hash / Digest

A fixed-size fingerprint of any data. One-way — you can’t reverse it.

SHA-256("hello") → 2cf24dba5fb0a30e...  (always 32 bytes)
SHA-256("hello ") → 98ea6e4f216f2fb4... (completely different)

Used for: integrity verification, password storage, key derivation, digital signatures.

Nonce

“Number used once.” A value that must never repeat with the same key. Used in encryption to ensure that encrypting the same plaintext twice produces different ciphertext.

encrypt(key, nonce=1, "hello") → 0x8a3f...
encrypt(key, nonce=2, "hello") → 0x2b7c...  (different!)

If you reuse a nonce with the same key, the encryption breaks — an attacker can recover plaintext. This is one of the most common crypto implementation mistakes.

Digital signature

The asymmetric equivalent of a handwritten signature. Proves who created a message and that it hasn’t been modified.

Alice signs:   signature = sign(Alice_private_key, message)
Bob verifies:  verify(Alice_public_key, message, signature) → true/false

If the message changes, verification fails. If someone else tries to sign, they can’t produce a valid signature without Alice’s private key.

MAC (Message Authentication Code)

A MAC is a short piece of data (a tag) that proves two things about a message:

  1. Integrity — the message wasn’t modified
  2. Authenticity — the message came from someone who knows the secret key

Think of it as a tamper-evident seal that only works with a secret:

Creating a MAC:
  Alice has: message + shared_key
  Alice computes: tag = MAC(shared_key, "transfer $100")
  Alice sends: message + tag

Verifying a MAC:
  Bob has: message + tag + shared_key
  Bob computes: expected_tag = MAC(shared_key, "transfer $100")
  Bob checks: tag == expected_tag?
    YES → message is authentic and unmodified
    NO  → message was tampered with or wrong key

Attacker (no key):
  Eve intercepts: message + tag
  Eve changes message to "transfer $900"
  Eve can't compute new tag (doesn't have the key)
  Bob checks → tag doesn't match → REJECTED

MAC vs Hash: a plain hash (Lesson 1) proves integrity but NOT authenticity — anyone can compute SHA-256("transfer $900") and replace the hash. A MAC requires the secret key, so only key holders can produce valid tags.

MAC vs Signature: a MAC uses a symmetric key (both sides share the same key). A signature uses an asymmetric key pair. MAC is faster but doesn’t prove which key holder created it (both sides have the same key). Signatures prove exactly who signed.

           MAC                    Signature
           ───                    ─────────
Key:       shared symmetric key   asymmetric key pair
Creates:   anyone with the key    only private key holder
Verifies:  anyone with the key    anyone with the public key
Proves:    "a key holder made it" "THIS person made it"
Speed:     fast                   slower
Used in:   TLS record integrity   TLS handshake authentication

HMAC: the most common MAC construction — built from a hash function (e.g., HMAC-SHA256). “Hash-based MAC.” Used extensively in TLS for key derivation (HKDF, Lesson 5) and handshake integrity.

AEAD (Authenticated Encryption with Associated Data)

Modern encryption that provides both confidentiality AND integrity in one operation. You get:

  • Ciphertext: encrypted data (confidentiality)
  • Authentication tag: proves the ciphertext wasn’t modified (integrity)

“Associated data” is metadata that’s authenticated but not encrypted (e.g., a message header).

encrypt(key, nonce, plaintext, associated_data) → (ciphertext, tag)
decrypt(key, nonce, ciphertext, tag, associated_data) → plaintext OR error

If anyone modifies the ciphertext, the tag, or the associated data, decryption fails.

Authentication vs Authorization

These are different concepts that are often confused:

  • Authentication (AuthN): “Who are you?” — verifying identity
    • Example: logging in with username/password, presenting a certificate
  • Authorization (AuthZ): “What are you allowed to do?” — verifying permissions
    • Example: “user X can read this file but not write to it”

TLS handles authentication (proving the server is who it claims to be). It does NOT handle authorization — that’s the application’s job.

TLS handshake: "I am server.example.com" (authentication)
Application:   "User alice can access /admin" (authorization)

Trust models

How do you decide to trust a public key?

Direct trust (pinned keys)

You manually verify and store the public key. Simple but doesn’t scale.

  • Example: SSH known_hosts, WireGuard peer configuration

Web of trust

People vouch for each other’s keys. Decentralized but messy.

  • Example: PGP/GPG key signing parties

Certificate authority (CA)

A trusted third party vouches for public keys by signing certificates. Hierarchical and scalable.

  • Example: HTTPS (Let’s Encrypt, DigiCert sign server certificates)

Trust on first use (TOFU)

Accept the key the first time you see it, alert if it changes.

  • Example: SSH (“The authenticity of host can’t be established… continue?”)

Forward secrecy

If an attacker records all your encrypted traffic today, and steals your long-term key next year, can they decrypt the recorded traffic?

  • Without forward secrecy: Yes. The long-term key decrypts everything.
  • With forward secrecy: No. Each session used ephemeral keys that were destroyed. The long-term key can’t help.

TLS 1.3 mandates forward secrecy by requiring ephemeral Diffie-Hellman key exchange.

The cast of characters

Cryptography literature uses standard names:

NameRole
AliceInitiator (usually the client)
BobResponder (usually the server)
EveEavesdropper — passively listens to traffic
MalloryActive attacker — can modify, inject, and replay messages
TrentTrusted third party (e.g., a Certificate Authority)

Common attacks

Attack              Who         What they do             Defense
─────────────────────────────────────────────────────────────────────
Eavesdropping       Eve         Listens to traffic       Encryption
Man-in-the-middle   Mallory     Intercepts + modifies    Authentication
Replay              Mallory     Re-sends old messages    Nonces / sequence #
Tampering           Mallory     Modifies ciphertext      AEAD / MAC
Downgrade           Mallory     Forces weak crypto       Signed handshake

Eavesdropping (passive)

Eve listens to network traffic. Defeated by encryption (confidentiality).

# See how easy eavesdropping is on unencrypted traffic:
# Terminal 1: start a plaintext HTTP server
python3 -m http.server 8000 &

# Terminal 2: capture traffic
sudo tcpdump -i lo0 port 8000 -A 2>/dev/null &

# Terminal 3: make a request
curl http://127.0.0.1:8000/

# tcpdump shows EVERYTHING in plaintext — the full HTTP request and response.
# This is why HTTPS exists.
kill %1 %2 2>/dev/null

Man-in-the-middle / MITM (active)

Mallory sits between Alice and Bob, impersonating each to the other. Defeated by authentication.

Alice ←──────→ Mallory ←──────→ Bob
  "Hi Bob"   →  reads it  →  "Hi Bob"
  "Hi Alice" ←  reads it  ←  "Hi Alice"

Both think they're talking to each other.
Mallory reads and modifies everything.

Replay attack

Mallory records a valid encrypted message and sends it again later. Defeated by sequence numbers or timestamps.

Alice sends:  "transfer $100" (encrypted, valid)
Mallory records it.
... 1 hour later ...
Mallory sends the same bytes again.
Server processes it → $100 transferred AGAIN.

Tampering

Mallory modifies an encrypted message in transit. Defeated by integrity checks (AEAD, MAC).

Downgrade attack

Mallory forces Alice and Bob to use weaker crypto than they’d normally choose. Defeated by signing the handshake negotiation.

See it in the real world

Every concept in this lesson is happening right now on your machine:

# See a real TLS handshake — every concept in action:
echo | openssl s_client -connect google.com:443 2>/dev/null | head -20
# You'll see: certificate chain (authentication), cipher suite (encryption),
# protocol version, key exchange algorithm

# See WHICH cipher suite was negotiated:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep "Cipher"
# Example: TLS_AES_256_GCM_SHA384
# That's: AEAD cipher (AES-GCM) + hash (SHA384)

# See the certificate (authentication):
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -subject -issuer
# subject: CN = *.google.com  ← who they claim to be
# issuer: CN = GTS CA 1C3     ← who vouches for them (CA)

# See forward secrecy in action:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep "Server Temp Key"
# Server Temp Key: X25519  ← ephemeral DH key exchange = forward secrecy
# See encryption protecting YOUR traffic right now:
# Capture some HTTPS traffic:
sudo tcpdump -i en0 -c 10 host google.com and port 443 -w /tmp/tls.pcap 2>/dev/null &
curl -s https://google.com > /dev/null
sleep 2 && kill %1 2>/dev/null

# Look at the raw bytes — all encrypted:
tcpdump -r /tmp/tls.pcap -X 2>/dev/null | tail -20
# You see hex garbage — that's AEAD encryption at work.
# Without the key, nobody can read it. Not your ISP, not the Wi-Fi owner.
# See HMAC (integrity) — on your own machine:
# Your SSH known_hosts uses HMAC to hash hostnames:
cat ~/.ssh/known_hosts | head -3
# Some lines start with |1|... — that's HMAC-hashed hostnames

# Package managers verify integrity with hashes:
# macOS:
shasum -a 256 $(which ls)
# The OS verified this hash when the binary was installed

How TLS uses all of this

TLS Handshake:
  1. Negotiate cipher suite          (which algorithms to use)
  2. Key exchange (DH)               (confidentiality + forward secrecy)
  3. Server certificate              (authentication via CA trust model)
  4. Server signature                (proves server has the private key)
  5. Key derivation (HKDF)           (derive session keys)
  6. Finished messages               (integrity of handshake — MAC)

TLS Record Protocol:
  7. AEAD encryption of data         (confidentiality + integrity)
  8. Sequence number nonces           (replay defense)
Every concept → a lesson:

  Concept              Lesson    What you'll build
  ──────────────────────────────────────────────────
  Hash                 1         SHA-256 file hasher
  Symmetric encryption 2         ChaCha20-Poly1305
  Signatures           3         Ed25519 sign/verify
  Key exchange         4         X25519 Diffie-Hellman
  Key derivation       5         HKDF from shared secret
  Password KDFs        6         Argon2 / PBKDF2
  Certificates         7         X.509 parsing
  Cert generation      8         Build a CA with rcgen
  Mini-TLS             9-12      Encrypted echo server
  TLS handshake        13        Protocol deep dive
  Real TLS             14-15     tokio-rustls + HTTPS

Every concept in this lesson maps to a specific part of TLS. The following lessons implement each piece in Rust.

Lesson 1: Hashing (SHA-256)

Alice’s Bookstore — Chapter 1

Alice sells digital books as PDF downloads. A customer named Carol emails her:

“The book I downloaded is 400 pages, but my friend got 402 pages from the same link. Did someone tamper with it? How do I know I got the real file?”

Bob suggests: “Publish a fingerprint of each file on your website. Customers download the book, compute the fingerprint themselves, and compare. If they match — the file is intact. If not — something changed it.”

“A fingerprint… of a file?”

“It’s called a hash.”

Real-life analogy: the fingerprint

Every person has a unique fingerprint. You can’t reconstruct a person from their fingerprint, but you can verify “is this the same person?” by comparing prints.

Person      → Fingerprint
"hello"     → 2cf24dba5fb0a30e...
Cargo.toml  → 1d3901bae4c11bd5...
Linux ISO   → e3b0c44298fc1c14...

Properties:
  ✓ Same person → same fingerprint (deterministic)
  ✗ Fingerprint → person (one-way, can't reverse)
  ✓ Twins look alike but have different prints (collision resistant)
  ✓ Tiny scar → completely different print (avalanche effect)

A hash function is a digital fingerprint machine.

What is a hash function?

A hash function takes any input — a single byte, a password, an entire movie — and produces a fixed-size output called a digest or hash.

SHA-256 always outputs 256 bits (32 bytes), no matter the input:

Input                          SHA-256 Output
──────────────────────────────────────────────────────────────────
"hello"                        2cf24dba5fb0a30e26e83b2ac5b9e29e...
"hello "  (added a space)      98ea6e4f216f2fb4b69fff9b3a44842c...
""  (empty string)             e3b0c44298fc1c149afbf4c8996fb924...
(4.7 GB Linux ISO)             a1b2c3d4... (still just 32 bytes)

The four properties

1. Deterministic

Same input → same hash. Always. On any machine, any OS, any time.

# Try it — these will give the same hash on every computer in the world:
echo -n "hello" | shasum -a 256
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

2. One-way (preimage resistant)

Given a hash, you cannot compute the original input. The only option is brute force — try every possible input until one matches.

Forward (easy):   "hello" → sha256 → 2cf24dba...     ✓ instant
Reverse (hard):   2cf24dba... → ??? → "hello"         ✗ impossible

Why? SHA-256 is a series of bit operations (AND, OR, XOR, rotate, add)
that are easy to compute forward but destroy information.
It's like mixing paint — easy to mix red + blue → purple,
impossible to unmix purple → red + blue.

3. Avalanche effect

Change one bit of input → completely different hash. The outputs are unrelated.

# One character difference:
echo -n "hello" | shasum -a 256
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

echo -n "hellp" | shasum -a 256
# 1f40fc92da241694750979ee6cf582f2d5d7d28e18335de05abc54d0560e0f53

# Completely different! Not a single hex digit in common.

4. Collision resistant

It’s practically impossible to find two different inputs with the same hash. SHA-256 has 2^256 possible outputs — more than atoms in the observable universe (~10^80).

                     How big is 2^256?
    ┌──────────────────────────────────────────────┐
    │  Atoms in the universe:     ~10^80           │
    │  2^256:                     ~10^77           │
    │                                              │
    │  If you hashed 1 billion inputs per second   │
    │  for the entire age of the universe           │
    │  (13.8 billion years), you'd have tried       │
    │  ~4 × 10^26 inputs.                          │
    │                                              │
    │  That's 0.000...0% of the hash space.        │
    │  Collision? Not in this universe.             │
    └──────────────────────────────────────────────┘

Try it yourself

Hash a string

# macOS / Linux:
echo -n "hello" | shasum -a 256
# The -n is important! Without it, echo adds a newline.

# Verify: with newline gives a DIFFERENT hash
echo "hello" | shasum -a 256
# Different! The newline character changed the input.

Hash a file

# Hash a file:
shasum -a 256 /etc/hosts

# On Linux, you can also use:
sha256sum /etc/hosts

Compare two files

# Create two files, one byte different:
echo -n "hello world" > file1.txt
echo -n "hello worle" > file2.txt

shasum -a 256 file1.txt file2.txt
# Completely different hashes — one byte changed everything.

Hash algorithms comparison

# Different hash functions, different output sizes:
echo -n "hello" | shasum -a 1       # SHA-1:   20 bytes — BROKEN, don't use
echo -n "hello" | shasum -a 256     # SHA-256: 32 bytes — standard
echo -n "hello" | shasum -a 512     # SHA-512: 64 bytes — extra security
echo -n "hello" | md5               # MD5:     16 bytes — BROKEN, don't use

Never use MD5 or SHA-1 for security — collisions have been found.

How SHA-256 works (simplified)

You don’t need to implement SHA-256, but understanding the structure helps:

Input: "hello" (5 bytes)
          │
          ▼
┌─────────────────────────────┐
│  Step 1: Padding            │  Add bits so length = multiple of 512 bits
│  Step 2: Split into blocks  │  One or more 512-bit blocks
│  Step 3: Initialize state   │  8 variables from √primes: h0..h7
│  Step 4: 64 rounds of mixing│  Rotate, shift, XOR, add constants
│  Step 5: Output h0..h7      │  Concatenate → 256-bit hash
└─────────────────────────────┘

Each round destroys information about the input.
After 64 rounds, recovering the input is infeasible.

Real-world uses

File integrity (Linux downloads)

# Every Linux distro publishes SHA-256 hashes:
wget https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso
wget https://releases.ubuntu.com/22.04/SHA256SUMS

# Verify:
shasum -a 256 -c SHA256SUMS
# ubuntu-22.04-desktop-amd64.iso: OK

Git

Every commit, tree, and blob is identified by its hash:

# See the hash of a commit:
git log --oneline -1

# See the hash git computes for a file:
git hash-object Cargo.toml

Password storage

WRONG — plaintext:
  Database: { user: "alice", password: "hunter2" }
  Attacker leaks DB → all passwords exposed

WRONG — plain SHA-256:
  Database: { user: "alice", hash: "f52fbd..." }
  Attacker uses rainbow table → cracked instantly

BETTER — SHA-256 + salt:
  Database: { user: "alice", salt: "x7k2", hash: "a3f1..." }
  Rainbow tables fail, but GPU brute force is fast

RIGHT — Argon2 (Lesson 6):
  Database: { user: "alice", hash: "$argon2id$..." }
  Intentionally slow → brute force impractical

Blockchain (Bitcoin)

Find an input where SHA-256(SHA-256(input)) starts with N zero bits:

# Simulate mining:
python3 -c "
import hashlib
for i in range(1000000):
    h = hashlib.sha256(f'block-nonce-{i}'.encode()).hexdigest()
    if h.startswith('0000'):
        print(f'Found! nonce={i} hash={h}')
        break
"

How TLS uses hashing

┌────────────────────────────────────────────────────────┐
│  Hashing in TLS                                        │
│                                                        │
│  1. HMAC (Lesson 5)                                    │
│     Hash + secret key → message authentication         │
│                                                        │
│  2. HKDF (Lesson 5)                                    │
│     Derive encryption keys from shared secret          │
│                                                        │
│  3. Handshake transcript (Lesson 13)                   │
│     Hash ALL handshake messages → detect tampering     │
│                                                        │
│  4. Certificate fingerprint                            │
│     Identify certs by hash → certificate pinning       │
│                                                        │
│  5. Finished message                                   │
│     HMAC of transcript → proves handshake integrity    │
└────────────────────────────────────────────────────────┘

Exercises

Exercise 1: File hasher

Build a CLI tool in Rust that takes a file path and prints its SHA-256 hash. Use the sha2 crate.

cargo run -p tls --bin 1-hash -- --file-path Cargo.toml
# Verify:
shasum -a 256 Cargo.toml

Exercise 2: Avalanche effect

Hash these two strings and compare:

"The quick brown fox jumps over the lazy dog"
"The quick brown fox jumps over the lazy dog."

One period. How many hex digits differ? (All of them.)

Exercise 3: Hash chain

Compute SHA-256(SHA-256(SHA-256("hello"))) — hash the hash of the hash.

# Verify with shell:
echo -n "hello" | shasum -a 256 | awk '{print $1}' | \
  xxd -r -p | shasum -a 256 | awk '{print $1}' | \
  xxd -r -p | shasum -a 256

Exercise 4: Commitment scheme

Build a commit-reveal protocol:

  1. Ask for a secret prediction
  2. Hash it, print the hash (commitment)
  3. Ask the user to reveal the prediction
  4. Hash the revealed text, compare
  5. Match → prediction was honest

Exercise 5: Hash speed benchmark

#![allow(unused)]
fn main() {
let start = Instant::now();
for i in 0..1_000_000 {
    let mut hasher = Sha256::new();
    hasher.update(i.to_be_bytes());
    hasher.finalize();
}
println!("{:.0} hashes/sec", 1_000_000.0 / start.elapsed().as_secs_f64());
}

Compare with openssl speed sha256. This shows why SHA-256 is too fast for passwords (Lesson 6).

Lesson 2: Symmetric Encryption (ChaCha20-Poly1305)

Alice’s Bookstore — Chapter 2

Alice’s bookstore is growing. Customers now buy books by sending their credit card numbers through her website. One afternoon, Bob shows her something alarming at the coffee shop:

“I’m on the same Wi-Fi as you. Watch this.”

He opens a packet sniffer and shows her the raw network traffic. There it is — a customer’s credit card number, in plaintext.

“Eve doesn’t even need to be a skilled hacker. Anyone on this Wi-Fi can see everything your customers send.”

“How do I hide it?”

“Encryption. You scramble the data so only you and the customer can read it. Everyone else sees random noise.”

“But we’d need to agree on a secret key first… how?”

“One problem at a time. First, let’s learn how encryption works. We’ll solve the key-sharing problem in Lesson 4.”

Real-life analogy: the lockbox with a shared key

Alice and Bob each have a copy of the same key for a lockbox:

Alice's side:                        Bob's side:
  ┌──────────────────┐               ┌──────────────────┐
  │ "meet at 3pm"    │               │                  │
  │    + key 🔑      │               │  ciphertext      │
  │    → lock 🔒     │──── send ────►│    + key 🔑      │
  │    → ciphertext  │               │    → unlock 🔓   │
  └──────────────────┘               │    → "meet at 3pm│
                                     └──────────────────┘

Eve intercepts:
  "x7#kQ!9pL@" ← meaningless without the key

Symmetric = same key locks and unlocks. Fast. Simple. The question is: how do Alice and Bob get the same key? (That’s Lesson 4.)

What symmetric encryption does

encrypt(key, nonce, plaintext) → ciphertext
decrypt(key, nonce, ciphertext) → plaintext

Key:        32 bytes (256 bits) — the shared secret
Nonce:      12 bytes — unique per message (explained below)
Plaintext:  your data (any size)
Ciphertext: same size as plaintext + 16 bytes (auth tag)

This is what TLS uses for all bulk data after the handshake. Fast — gigabytes per second.

Try it yourself

# Encrypt a file with OpenSSL using ChaCha20:
echo "secret message" > plain.txt
openssl enc -chacha20 -in plain.txt -out encrypted.bin \
  -K $(openssl rand -hex 32) -iv $(openssl rand -hex 16)

# The encrypted file is unreadable:
xxd encrypted.bin | head -3

# See AES hardware acceleration on your CPU:
# macOS:
sysctl -a | grep -i aes
# hw.optional.aes: 1  ← your CPU has AES-NI

# Linux:
grep -o aes /proc/cpuinfo | head -1

The old problem: encryption without authentication

Old ciphers (AES-CBC) only gave you confidentiality:

Old encryption (AES-CBC):
  plaintext: "transfer $100 to Bob"
  encrypt → ciphertext: 0x7a3f8b2e1c...

  Attacker can't READ it                    ✓
  But attacker CAN flip bits:               ✗
    0x7a3f8b2e1c... → 0x7a3f8b2e9c...
    Decrypts to: "transfer $900 to Bob"

  Nobody detects the tampering!
  This is the "padding oracle" family of attacks.

AEAD: Authenticated Encryption with Associated Data

Modern encryption combines encryption + integrity in one operation:

┌──────────────────────────────────────────────────────┐
│  AEAD Output                                         │
│                                                      │
│  ┌─────────────────────────┬──────────┐              │
│  │  Ciphertext             │ Auth Tag │              │
│  │  (same length as        │ (16 bytes│              │
│  │   plaintext, encrypted) │  MAC)    │              │
│  └─────────────────────────┴──────────┘              │
│                                                      │
│  On decrypt:                                         │
│    1. Verify the tag — was anything modified?         │
│    2. If tag invalid → ERROR (reject immediately)    │
│    3. If tag valid → decrypt → return plaintext      │
│                                                      │
│  Attacker flips one bit of ciphertext?               │
│    → Tag doesn't match → decryption FAILS            │
│    → No corrupted data ever reaches your code        │
└──────────────────────────────────────────────────────┘

ChaCha20-Poly1305

TLS 1.3 supports exactly two AEAD ciphers:

                    AES-256-GCM          ChaCha20-Poly1305
─────────────────────────────────────────────────────────────
Encryption          AES (block cipher)   ChaCha20 (stream cipher)
Authentication      GHASH                Poly1305
Key / Nonce / Tag   256b / 96b / 128b   256b / 96b / 128b
Hardware accel      AES-NI (Intel/AMD)   None needed
Software speed      Slow without AES-NI  Fast everywhere
Used by             Most servers          Mobile, IoT, WireGuard

How ChaCha20 works (simplified)

ChaCha20 is a stream cipher — it generates a pseudorandom keystream XORed with plaintext:

ChaCha20(key, nonce, counter) → keystream

plaintext:  "hello world!"
keystream:  0x7a3f8b2e1c...   (deterministic from key+nonce)
ciphertext: plaintext XOR keystream

To decrypt: ciphertext XOR keystream = plaintext
            (XOR undoes itself)

How Poly1305 works (simplified)

Poly1305 computes a 16-byte tag over the ciphertext:

Poly1305(key, ciphertext) → tag (16 bytes)

Change one bit of ciphertext → completely different tag.
Receiver recomputes tag and compares — mismatch = tamper detected.

Nonces: the most dangerous footgun

Every encryption call takes a nonce (number used once) — 12 bytes.

The absolute rule: never reuse a nonce with the same key.

Why nonce reuse is catastrophic

Same key + same nonce → same keystream:

Message 1: "hello world!" XOR keystream = ciphertext_1
Message 2: "secret msg!!" XOR keystream = ciphertext_2
                               ↑ SAME keystream!

Attacker computes:
  ciphertext_1 XOR ciphertext_2
  = plaintext_1 XOR plaintext_2       ← keystreams cancel out!

From plaintext XOR, frequency analysis recovers both messages.
# Demonstrate XOR cancellation:
python3 -c "
a = b'hello world!'
b = b'secret msg!!'
xor = bytes(x ^ y for x, y in zip(a, b))
print(f'plaintext XOR: {xor.hex()}')
print('This is what the attacker gets from nonce reuse.')
"

How TLS avoids nonce reuse

TLS uses a counter: message 0 → nonce 0, message 1 → nonce 1, etc. Simple, bulletproof.

Real-world nonce disasters

  • 2016 TLS: nonce reuse across servers when session tickets were shared. Plaintext recovered.
  • PS3 (2010): Sony used the same nonce for every ECDSA signature. Private key recovered.
  • WPA2 KRACK (2017): Wi-Fi nonce reuse allowed decryption of packets.

Associated Data: authenticated but not encrypted

AEAD can authenticate plaintext metadata alongside encrypted data:

┌──────────────────────────────────────────────────┐
│  Associated data (plaintext, tamper-proof):       │
│    "message-id: 42, type: chat"                  │
│                                                  │
│  Encrypted payload:                              │
│    "meet at 3pm" → 0x7a3f8b...                  │
│                                                  │
│  Auth tag covers BOTH:                           │
│    Modify the header? Tag fails.                 │
│    Modify the ciphertext? Tag fails.             │
└──────────────────────────────────────────────────┘

TLS uses this for record headers — the header is plaintext but authenticated.

Benchmark on your machine

# Compare AES-GCM vs ChaCha20 on your hardware:
openssl speed -evp chacha20-poly1305
openssl speed -evp aes-256-gcm
# AES-GCM is usually faster with AES-NI hardware
# ChaCha20 wins on devices without AES-NI

Exercises

Exercise 1: Encrypt, decrypt, tamper

Encrypt a message with ChaCha20-Poly1305. Decrypt it. Flip one byte of ciphertext, try to decrypt — show the error.

Exercise 2: Nonce reuse attack

Encrypt two messages with the same key and nonce. XOR the ciphertexts (skip the tag). Compare with XORing the plaintexts — they match. This proves nonce reuse leaks plain1 XOR plain2.

Exercise 3: Counter nonce

Encrypt 10 messages with counter nonces (0, 1, 2, …). Decrypt each. Try decrypting message 5 with nonce 3 — should fail.

#![allow(unused)]
fn main() {
fn counter_nonce(n: u64) -> [u8; 12] {
    let mut nonce = [0u8; 12];
    nonce[4..12].copy_from_slice(&n.to_be_bytes());
    nonce
}
}

Exercise 4: Associated data

Encrypt with AAD. Decrypt with correct AAD — works. Decrypt with modified AAD — fails. The metadata is plaintext but tamper-proof.

Exercise 5: Large file encryption

Encrypt a 1MB file in 4KB chunks. Each chunk uses nonce = chunk index. Decrypt all chunks, reassemble, verify SHA-256 matches the original. This is how TLS encrypts a data stream.

Lesson 3: Asymmetric Crypto & Signatures (Ed25519)

Alice’s Bookstore — Chapter 3

Alice starts emailing order confirmations to customers. One day, a customer named Dave calls:

“I got an email from ‘Alice’s Bookstore’ saying my order was cancelled and asking me to re-enter my credit card on a new link. Is that real?”

It wasn’t. Someone — let’s call her Mallory — sent a fake email pretending to be Alice. The email looked identical to Alice’s real ones. Dave almost entered his credit card on Mallory’s phishing site.

“How can my customers tell that a message is really from ME and not a fake?”

Bob explains: “You need a digital signature. You sign your messages with a private key that only you have. Anyone can verify the signature using your public key. Mallory can’t forge it because she doesn’t have your private key.”

“So it’s like a wax seal that only I can stamp?”

“Exactly.”

Real-life analogy: the wax seal

In medieval times, kings sealed letters with a wax stamp pressed from a unique signet ring:

King's ring (private key):
  Only the king has it. Never leaves his finger.

Wax impression (signature):
  Anyone can SEE it and verify it matches the king's seal.
  Nobody can FORGE it without the ring.

Royal seal catalog (public key):
  Everyone knows what the king's seal looks like.
  They compare the wax impression against the catalog.

  ┌──────────┐     ┌───────────┐     ┌───────────┐
  │ Letter   │     │ Wax seal  │     │ Catalog   │
  │ "attack  │ +   │ (made with│     │ (king's   │
  │  at dawn"│     │  ring)    │     │  known    │
  └──────────┘     └───────────┘     │  seal)    │
       │                │             └─────┬─────┘
       └────────┬───────┘                   │
                ▼                           ▼
         Does the seal match?          Compare!
         Was the letter modified?      ✓ or ✗

Digital signatures work the same way: sign with private key, verify with public key.

The problem symmetric crypto can’t solve

In Lesson 2, both sides need the same key. But how do you share it? You can’t send it over the network — anyone watching would see it. You can’t encrypt it — you’d need another key for that (chicken-and-egg).

Asymmetric crypto solves this with key pairs:

  • Private key: kept secret, never leaves your machine
  • Public key: given to everyone

Two uses of key pairs

                    Private Key              Public Key
                    (secret)                 (shared with everyone)
─────────────────────────────────────────────────────────────────
Encryption:         decrypt                  encrypt
                    Only you can read        Anyone can send you
                    messages to you          encrypted messages

Signatures:         sign                     verify
                    Only you can sign        Anyone can check
                    (proves authorship)      your signature

1. Encryption (less common in modern TLS)

  • Encrypt with someone’s public key → only their private key can decrypt
  • Used in older TLS (RSA key exchange), but NOT in TLS 1.3

2. Digital signatures (critical in TLS)

  • Sign with your private key → anyone with your public key can verify
  • Proves two things:
    • Authenticity: “this message was created by the private key holder”
    • Integrity: “this message hasn’t been modified since signing”

Try it yourself

# Generate an Ed25519 key pair with OpenSSL:
openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

# Look at the keys:
cat private.pem   # PEM-encoded private key
cat public.pem    # PEM-encoded public key

# Sign a file:
echo "important document" > doc.txt
openssl pkeyutl -sign -inkey private.pem -in doc.txt -out doc.sig

# Verify the signature:
openssl pkeyutl -verify -pubin -inkey public.pem -in doc.txt -sigfile doc.sig
# Signature Verified Successfully

# Tamper with the document and verify again:
echo "modified document" > doc.txt
openssl pkeyutl -verify -pubin -inkey public.pem -in doc.txt -sigfile doc.sig
# Signature Verification Failure
# See SSH host keys (Ed25519 is typically one of them):
ls -la /etc/ssh/ssh_host_*key*
# ssh_host_ed25519_key      ← private key (permissions: 600)
# ssh_host_ed25519_key.pub  ← public key

# See your SSH known_hosts (server public keys you've trusted):
cat ~/.ssh/known_hosts | head -3

# See your own SSH public key:
cat ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No Ed25519 SSH key found"

# Generate one if you don't have it:
# ssh-keygen -t ed25519

Ed25519

A modern signature algorithm based on elliptic curves (Curve25519). Designed by Daniel Bernstein.

sign(private_key, message) → signature (64 bytes)
verify(public_key, message, signature) → true/false
┌─────────────────────────────────────────────────────────┐
│  Ed25519 at a glance                                    │
│                                                         │
│  Private key:   32 bytes                                │
│  Public key:    32 bytes (derived from private key)     │
│  Signature:     64 bytes                                │
│  Speed:         ~15,000 signatures/second               │
│  Deterministic: yes (no random nonce needed)            │
│                                                         │
│  Used by: SSH, WireGuard, Signal, TLS, git signing,    │
│           cargo, age encryption, minisign               │
└─────────────────────────────────────────────────────────┘

Key property: deterministic. Same key + same message → same signature every time. Unlike ECDSA, there’s no random nonce — which means no nonce reuse bugs (recall the PS3 disaster from Lesson 2).

Real-world scenarios

Alice signs a software release

Alice publishes open-source software. Users need to verify downloads are genuinely from Alice, not an attacker who compromised the download mirror.

  1. Alice generates an Ed25519 key pair. Publishes her public key on her website.
  2. Alice builds version 2.0, signs the binary: sign(alice_private, binary) → sig
  3. Alice uploads binary and sig to the download mirror
  4. Bob downloads both. He has Alice’s public key from her website.
  5. Bob runs verify(alice_public, binary, sig) → success
  6. An attacker modifies the binary on the mirror. Bob downloads it.
  7. Bob runs verify(alice_public, modified_binary, sig)FAILS

Bob knows the binary was tampered with. This is exactly how apt (Debian/Ubuntu) and cargo verify packages.

Bob authenticates to a server (SSH)

When you run ssh server.com, the server proves its identity:

  1. Server has a long-term Ed25519 key pair (in /etc/ssh/ssh_host_ed25519_key)
  2. During SSH handshake, server signs session data with its private key
  3. Client verifies the signature against the server’s known public key (in ~/.ssh/known_hosts)
  4. If verification fails → “WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!”

This prevents MITM attacks: an attacker can’t forge the server’s signature without its private key.

How TLS uses signatures

During the TLS handshake:

  1. Server sends its ephemeral DH public key (for key exchange, Lesson 4)
  2. Server signs the handshake transcript (all messages so far) with its long-term private key
  3. Client verifies the signature using the server’s public key from the certificate (Lesson 6)
  4. If the signature is valid → the client knows the DH public key genuinely came from the server
  5. An attacker can’t forge this because they don’t have the server’s private key

Without this signature, an attacker could substitute their own DH public key (man-in-the-middle attack).

Ed25519 vs RSA vs ECDSA

Algorithm    Key size     Sig size    Speed        Nonce risk
──────────────────────────────────────────────────────────────
RSA-2048     256 bytes    256 bytes   Slow         No
ECDSA P-256  64 bytes     64 bytes    Medium       YES (fatal!)
Ed25519      32 bytes     64 bytes    Fast         No (deterministic)
  • RSA: oldest, huge keys, being phased out. Still used by many CAs.
  • ECDSA: smaller keys, but has a dangerous nonce. If the random nonce leaks or is reused, the private key can be recovered. This happened to Sony’s PS3 signing key (2010).
  • Ed25519: smallest keys, fastest, deterministic (no nonce footgun). The modern choice.
# Benchmark signing speed on your machine:
openssl speed ed25519 ecdsa rsa2048 2>/dev/null | grep -E 'sign|verify'

How TLS uses signatures

TLS Handshake:
  ┌────────┐                              ┌────────┐
  │ Client │                              │ Server │
  └───┬────┘                              └───┬────┘
      │                                       │
      │◄── server's DH public key ────────────│
      │◄── server's certificate ──────────────│
      │◄── signature over handshake ──────────│  ← signed with server's
      │                                       │    private key
      │                                       │
      │  Client verifies:                     │
      │    1. Certificate → trusted CA?       │
      │    2. Signature → matches public key? │
      │    3. Both pass → server is genuine   │
      │                                       │
      │  Without signature:                   │
      │    Attacker substitutes own DH key    │
      │    → man-in-the-middle!               │

Exercises

Exercise 1: Sign and verify (implemented in 3-sign.rs)

Generate a key pair, sign a message, verify it. Then modify the message and show verification fails.

Exercise 2: Sign multiple messages

Sign three different messages with the same key. Verify each with the corresponding message. Then try verifying message 1’s signature against message 2 — it should fail. Each signature is bound to its specific message.

Exercise 3: Key separation

Generate two different key pairs. Sign the same message with both. Show that:

  • Key A’s signature verifies with Key A’s public key
  • Key A’s signature does NOT verify with Key B’s public key
  • Key B’s signature does NOT verify with Key A’s public key

This demonstrates that signatures are bound to both the message AND the signer’s identity.

Exercise 4: Detached signatures (real-world pattern)

Simulate a software release workflow:

  1. Create a “binary” (any byte array)
  2. Sign it, save the signature to a separate “file” (Vec<u8>)
  3. In a separate function (simulating a different machine), load the “binary” and “signature”, verify against a hardcoded public key

Exercise 5: Verify on the command line

Generate keys and sign a file entirely from the CLI, then verify in your Rust program:

# Generate key pair:
openssl genpkey -algorithm Ed25519 -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem

# Sign:
echo -n "verify me" > msg.txt
openssl pkeyutl -sign -inkey key.pem -in msg.txt -out msg.sig

# Now write Rust code that reads pub.pem and msg.sig, verifies msg.txt

This bridges the CLI tools with your Rust code — the same keys and signatures work in both.

Lesson 4: Diffie-Hellman Key Exchange (X25519)

Alice’s Bookstore — Chapter 4

Alice now understands encryption (Lesson 2) — she can scramble data so Eve can’t read it. But there’s a chicken-and-egg problem:

“To encrypt, my customer and I need the same secret key. But how do we agree on a key? If I send it over the network, Eve sees it. If I encrypt it… I need a key to encrypt the key. It’s turtles all the way down!”

Bob smiles: “This is the most elegant trick in cryptography. You and the customer can agree on a shared secret by exchanging messages in public — and even if Eve records every single byte you send, she STILL can’t figure out the secret.”

“That sounds impossible.”

“It’s called Diffie-Hellman. Let me show you with paint.”

Real-life analogy: mixing paint

Alice and Bob want to agree on a shared secret color. Eve is watching everything they send.

Public:  Both agree on base color: YELLOW

Alice (secret: RED)              Bob (secret: BLUE)
  │                                │
  │ mix RED + YELLOW → ORANGE      │ mix BLUE + YELLOW → GREEN
  │                                │
  ├──── sends ORANGE ────────────►│
  │◄──── sends GREEN ─────────────┤
  │                                │
  │ mix GREEN + RED → BROWN        │ mix ORANGE + BLUE → BROWN
  │                                │
  └── shared secret: BROWN ────────┘── shared secret: BROWN

Eve sees: YELLOW, ORANGE, GREEN
Eve CANNOT unmix paint to get BROWN

This is Diffie-Hellman. Replace “colors” with “math” and it’s the real thing.

The core problem

Alice and Bob want to encrypt their communication (Lesson 2), but they need a shared secret key. They can’t send it in plaintext — Eve is watching the network. They can’t encrypt it — that requires a key they don’t have yet (chicken-and-egg).

The magic trick (paint analogy)

  1. Alice and Bob publicly agree on a base color: yellow
  2. Alice picks a secret color: red. Mixes red + yellow → orange. Sends orange to Bob.
  3. Bob picks a secret color: blue. Mixes blue + yellow → green. Sends green to Alice.
  4. Alice mixes Bob’s green + her secret red → brown
  5. Bob mixes Alice’s orange + his secret blue → same brown

Eve sees yellow, orange, and green — but can’t unmix paint to get brown. That’s Diffie-Hellman.

The math (simplified)

With numbers and modular arithmetic:

Public parameters: p = 23 (prime), g = 5 (generator)

Alice                               Bob
picks secret a = 6                  picks secret b = 15

A = g^a mod p                      B = g^b mod p
A = 5^6 mod 23 = 8                 B = 5^15 mod 23 = 19

sends A = 8 ──────────────────►    receives A = 8
receives B = 19 ◄──────────────    sends B = 19

shared = B^a mod p                 shared = A^b mod p
       = 19^6 mod 23 = 2                 = 8^15 mod 23 = 2

Both get 2. Why?

Alice: B^a = (g^b)^a = g^(b*a) mod p
Bob:   A^b = (g^a)^b = g^(a*b) mod p
a*b == b*a, so they're equal.

Eve sees g=5, p=23, A=8, B=19. To find the shared secret, she’d need to solve 5^a mod 23 = 8 for a — the discrete logarithm problem. With small numbers it’s trivial, but with 256-bit numbers it’s computationally infeasible.

X25519: the modern version

Instead of g^a mod p, X25519 uses elliptic curve point multiplication:

  • Secret key a = random 32 bytes
  • Public key A = a * G (multiply base point G on Curve25519 by scalar a)
  • Shared secret = a * B = a * (b * G) = b * (a * G) = b * A

Same principle, different math. Elliptic curves give equivalent security with much smaller keys (32 bytes vs 2048+ bytes for classic DH).

Try it yourself

# Generate an X25519 key pair with OpenSSL:
openssl genpkey -algorithm X25519 -out alice_private.pem
openssl pkey -in alice_private.pem -pubout -out alice_public.pem

openssl genpkey -algorithm X25519 -out bob_private.pem
openssl pkey -in bob_private.pem -pubout -out bob_public.pem

# Derive the shared secret (Alice's side):
openssl pkeyutl -derive -inkey alice_private.pem \
  -peerkey bob_public.pem -out shared_alice.bin

# Derive the shared secret (Bob's side):
openssl pkeyutl -derive -inkey bob_private.pem \
  -peerkey alice_public.pem -out shared_bob.bin

# Verify they match:
xxd shared_alice.bin
xxd shared_bob.bin
# Same 32 bytes! Alice and Bob derived the same secret.
# See what key exchange a real TLS connection uses:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep -i "Server Temp Key"
# Server Temp Key: X25519, 253 bits

# See the full handshake showing key exchange:
echo | openssl s_client -connect example.com:443 -state 2>&1 | grep -i "key"
# Verify DH with Python (small numbers, for learning):
python3 -c "
p, g = 23, 5
a, b = 6, 15  # secrets

A = pow(g, a, p)  # Alice's public: 5^6 mod 23 = 8
B = pow(g, b, p)  # Bob's public: 5^15 mod 23 = 19

shared_alice = pow(B, a, p)  # 19^6 mod 23 = 2
shared_bob   = pow(A, b, p)  # 8^15 mod 23 = 2

print(f'Alice public: {A}, Bob public: {B}')
print(f'Alice shared: {shared_alice}, Bob shared: {shared_bob}')
print(f'Match: {shared_alice == shared_bob}')
"

Real-world scenarios

Alice and Bob establish an encrypted chat session

Alice and Bob have never communicated before. They want to set up end-to-end encryption.

  1. Alice generates an ephemeral X25519 key pair: (alice_secret, alice_public)
  2. Bob generates an ephemeral X25519 key pair: (bob_secret, bob_public)
  3. Alice sends alice_public (32 bytes) to Bob over the internet
  4. Bob sends bob_public (32 bytes) to Alice over the internet
  5. Alice computes: shared = alice_secret.dh(bob_public) → 32-byte secret
  6. Bob computes: shared = bob_secret.dh(alice_public) → same 32-byte secret
  7. Both use this shared secret as an encryption key (or derive keys via HKDF, Lesson 5)
  8. Both destroy their ephemeral secrets

Eve recorded all traffic. She has alice_public and bob_public. She cannot compute the shared secret.

Forward secrecy in TLS

Every TLS connection generates fresh ephemeral keys:

  1. Monday: Client and server do DH → shared_1. Encrypt traffic. Destroy ephemeral keys.
  2. Tuesday: Client and server do DH → shared_2. Encrypt traffic. Destroy ephemeral keys.
  3. Wednesday: Attacker compromises the server’s long-term private key.

The attacker recorded Monday’s and Tuesday’s encrypted traffic. Can they decrypt it? No. The ephemeral DH keys are gone. shared_1 and shared_2 can never be reconstructed. This is forward secrecy.

Without ephemeral DH (old RSA key exchange): the attacker uses the long-term key to decrypt ALL past traffic. This is why TLS 1.3 removed RSA key exchange entirely.

With ephemeral DH (TLS 1.3):        Without (old RSA):
  Mon: DH → key_1 → destroyed         Mon: RSA decrypt → key_1
  Tue: DH → key_2 → destroyed         Tue: RSA decrypt → key_2
  Wed: attacker gets long-term key     Wed: attacker gets RSA key
       ↓                                    ↓
  Can decrypt Mon traffic? NO           Can decrypt Mon? YES
  Can decrypt Tue traffic? NO           Can decrypt Tue? YES
  Keys are gone forever.                All past traffic exposed.

WireGuard’s Noise protocol

WireGuard uses X25519 for both:

  • Static keys: long-term identity (like a certificate)
  • Ephemeral keys: per-session (forward secrecy)

The handshake does multiple DH operations: static-static, static-ephemeral, ephemeral-ephemeral. This gives authentication AND forward secrecy in one round trip.

The man-in-the-middle problem

DH alone does NOT authenticate. Mallory (attacker) can intercept:

Alice                   Mallory                  Bob
  │                        │                       │
  ├── alice_pub ──────────►│                       │
  │                        ├── mallory_pub1 ──────►│
  │                        │◄── bob_pub ───────────┤
  │◄── mallory_pub2 ──────┤                       │
  │                        │                       │
  │ shared_AM              │ shared_AM, shared_MB  │ shared_MB
  │ (Alice↔Mallory)        │ (can read EVERYTHING) │ (Mallory↔Bob)
  │                        │                       │
  │ Thinks she's           │ Decrypts, reads,      │ Thinks he's
  │ talking to Bob         │ re-encrypts, forwards │ talking to Alice

Mallory does two separate key exchanges. She reads everything. Neither side knows.

This is why Lessons 3 and 7 (signatures and certificates) are necessary — they authenticate the DH public keys.

Exercises

Exercise 1: Key exchange (implemented in 4-keyexchange.rs)

Simulate Alice and Bob. Generate ephemeral keys, exchange public keys, compute shared secrets. Print both — they must match.

Exercise 2: Ephemeral means unique

Run the key exchange three times. Print the shared secret each time. All three should be different — demonstrating that each session gets a unique key.

Exercise 3: Wrong public key

Alice does DH with Bob’s public key. Charlie does DH with Bob’s public key using a DIFFERENT secret. Show that Alice and Charlie get different shared secrets — only the matching pair produces the same result.

Exercise 4: Simulate man-in-the-middle

Implement Mallory intercepting the exchange:

  1. Alice generates keys, sends alice_public to Mallory (thinking it’s Bob)
  2. Mallory generates her own keys, sends mallory_public to Alice (pretending to be Bob)
  3. Mallory sends mallory_public2 to Bob (pretending to be Alice)
  4. Bob sends bob_public to Mallory
  5. Mallory now has two different shared secrets: one with Alice, one with Bob
  6. Show that Alice’s shared secret != Bob’s shared secret (they’re not talking to each other)

Project: TOTP Authenticator

Prerequisites: Lesson 1 (Hashing), Lesson 5 (HMAC). This project uses HMAC-SHA1 to generate time-based codes.

What is TOTP?

TOTP is the 6-digit code on your phone that changes every 30 seconds. It’s the most common form of two-factor authentication (2FA):

┌──────────────────────────────────────────────────────┐
│  Login flow with TOTP                                │
│                                                      │
│  1. You enter username + password                    │
│  2. Server says: "Enter your 2FA code"               │
│  3. You open Google Authenticator                    │
│  4. It shows: 847 293  (changes in 14 seconds)       │
│  5. You type 847293                                  │
│  6. Server verifies → access granted                 │
│                                                      │
│  If attacker steals your password:                   │
│    They still can't log in without the code.         │
│    The code changes every 30 seconds.                │
│    It's derived from a secret only you and the       │
│    server share.                                     │
└──────────────────────────────────────────────────────┘

How TOTP works

The algorithm is simple — just 5 steps:

Shared secret (base32)          Current time
"JBSWY3DPEHPK3PXP"             1714000000 (Unix timestamp)
        │                              │
        ▼                              ▼
   Decode base32                floor(time / 30)
   → raw bytes                  → 57133333 (time step)
        │                              │
        └──────────┬───────────────────┘
                   │
                   ▼
        HMAC-SHA1(secret, time_step as u64 big-endian)
                   │
                   ▼
           20-byte HMAC output
                   │
                   ▼
        Dynamic truncation (extract 4 bytes)
                   │
                   ▼
            31-bit integer
                   │
                   ▼
          integer mod 1,000,000
                   │
                   ▼
           6-digit code: 847293

Step 1: The shared secret

When you scan a QR code to set up 2FA, you’re receiving a shared secret — typically 20 bytes encoded in base32:

Base32: JBSWY3DPEHPK3PXP
Bytes:  [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0xde, 0xad, 0xbe, 0xef]

Both your phone and the server store this secret. It never travels over the network again.

Step 2: Time step

Divide the current Unix timestamp by 30 (the time window):

T = floor(unix_timestamp / 30)

Example:
  timestamp = 1714000000
  T = floor(1714000000 / 30) = 57133333

This means the code changes every 30 seconds. Everyone in the same 30-second window computes the same T.

Step 3: HMAC-SHA1

Compute HMAC-SHA1(secret, T) where T is an 8-byte big-endian integer:

T = 57133333
T as u64 big-endian = [0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0xA1, 0x55]

HMAC-SHA1(secret, T_bytes) → 20 bytes

Step 4: Dynamic truncation

Take the last nibble (4 bits) of the HMAC as an offset, then extract 4 bytes starting at that offset:

#![allow(unused)]
fn main() {
let offset = (hmac_result[19] & 0x0F) as usize;
let code = u32::from_be_bytes([
    hmac_result[offset] & 0x7F,  // mask high bit (ensure positive)
    hmac_result[offset + 1],
    hmac_result[offset + 2],
    hmac_result[offset + 3],
]);
}

Step 5: Modulo

code = truncated_value % 1_000_000
// Gives a 6-digit number (zero-padded)
// Example: 847293

That’s it. The entire TOTP algorithm is about 15 lines of code.

Try it before coding

# Install oathtool (TOTP reference implementation):
# macOS:
brew install oath-toolkit
# Linux:
sudo apt install oathtool

# Generate a TOTP code:
oathtool --totp -b "JBSWY3DPEHPK3PXP"
# Output: a 6-digit code (changes every 30 seconds)

# Wait 30 seconds, run again — different code:
sleep 30 && oathtool --totp -b "JBSWY3DPEHPK3PXP"

# Show the code for a specific time (for testing):
oathtool --totp -b "JBSWY3DPEHPK3PXP" --now "2024-04-25 12:00:00 UTC"
# Same thing in Python:
pip3 install pyotp
python3 -c "
import pyotp, time
totp = pyotp.TOTP('JBSWY3DPEHPK3PXP')
code = totp.now()
remaining = 30 - (int(time.time()) % 30)
print(f'Code: {code} (expires in {remaining}s)')
print(f'Valid? {totp.verify(code)}')
"
# See the current time step:
python3 -c "
import time
now = int(time.time())
step = now // 30
remaining = 30 - (now % 30)
print(f'Unix time:  {now}')
print(f'Time step:  {step}')
print(f'Next code in: {remaining}s')
"

The QR code (otpauth:// URI)

When a website shows a QR code for 2FA setup, it encodes a URI:

otpauth://totp/MyService:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyService&digits=6&period=30
otpauth://totp/              ← type (TOTP vs HOTP)
MyService:alice@example.com  ← label shown in the app
?secret=JBSWY3DPEHPK3PXP   ← the shared secret (base32, often unpadded)
&issuer=MyService            ← company name
&digits=6                    ← code length (6 or 8)
&period=30                   ← time step in seconds
&algorithm=SHA1              ← hash algorithm

Most parameters are optional. A real URI often looks like:

otpauth://totp/ISSUER%3Ausername?secret=MSITKRCX7CVPGFFKHMSSNYL7YB&issuer=ISSUER

Only secret is required. Missing parameters use defaults:

Parameter   Default   Notes
───────────────────────────────────
secret      (required) base32, often without '=' padding
algorithm   SHA1       SHA1, SHA256, SHA512
digits      6          6 or 8
period      30         seconds
issuer      (optional) display name in the app

Your parser must handle missing fields with defaults:

#![allow(unused)]
fn main() {
let algorithm = params.get("algorithm").unwrap_or(&"SHA1".to_string());
let digits: u32 = params.get("digits").map(|d| d.parse().unwrap()).unwrap_or(6);
let period: u64 = params.get("period").map(|p| p.parse().unwrap()).unwrap_or(30);
}
# Generate a QR code from the URI:
# macOS: brew install qrencode
# Linux: sudo apt install qrencode

qrencode -o totp-qr.png \
  "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"
open totp-qr.png  # macOS
# Scan with Google Authenticator — it will start showing codes!

The RFC (6238)

TOTP is defined in RFC 6238:

Parameter    Default    Description
─────────────────────────────────────────
Algorithm    SHA-1      Hash for HMAC (SHA-1, SHA-256, SHA-512)
Digits       6          Length of code (6 or 8)
Period       30         Seconds per time step
T0           0          Unix epoch start

Most services use SHA-1 + 6 digits + 30 seconds.

Implementation guide

We’ll build this step by step. At each step, you can compile and test before moving on.

Step 0: Project setup

Create the binary and add dependencies:

# If adding to the tls crate, create the file:
touch tls/src/bin/p1-totp.rs

Add to tls/Cargo.toml:

[dependencies]
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"  # for base32 decoding
clap = { version = "4", features = ["derive"] }

Start with a skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Generate a TOTP code
    Generate { secret: String },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Generate { secret } => {
            println!("TODO: generate TOTP for {}", secret);
        }
    }
}
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP
# Should print the TODO message

Step 1: Decode the base32 secret

The shared secret comes as a base32 string. Decode it to raw bytes:

#![allow(unused)]
fn main() {
fn decode_secret(secret_base32: &str) -> Vec<u8> {
    // Most TOTP URIs strip the base32 padding ('=' signs).
    // Use BASE32_NOPAD to handle both padded and unpadded secrets.
    data_encoding::BASE32_NOPAD
        .decode(secret_base32.as_bytes())
        .expect("invalid base32 secret")
}
}

Why BASE32_NOPAD? Base32 normally requires padding to a multiple of 8 characters (e.g., JBSWY3DPEHPK3PXP=======). But most TOTP services strip the = padding from the otpauth:// URI. If you use strict BASE32, secrets like MSITKRCX7CVPGFFKHMSSNYL7YB (26 chars, not a multiple of 8) will fail to decode.

Secret from URI:  MSITKRCX7CVPGFFKHMSSNYL7YB        ← 26 chars, no padding
BASE32 expects:   MSITKRCX7CVPGFFKHMSSNYL7YB======  ← padded to 32
BASE32_NOPAD:     accepts both — use this one

Test it:

fn main() {
    // Padded secret (some services include padding):
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    println!("Secret bytes: {:?}", secret);
    println!("Length: {} bytes", secret.len());

    // Unpadded secret (most real URIs look like this):
    let secret2 = decode_secret("MSITKRCX7CVPGFFKHMSSNYL7YB");
    println!("Secret2 bytes: {:?}", secret2);
    println!("Length: {} bytes", secret2.len());
}
# Verify with Python (Python's b32decode also needs padding):
python3 -c "
import base64
# Python needs padding, so we add it:
secret = 'MSITKRCX7CVPGFFKHMSSNYL7YB'
padded = secret + '=' * (-len(secret) % 8)
print(list(base64.b32decode(padded)))
"

Step 2: Compute the time step

Get the current Unix timestamp and divide by 30:

#![allow(unused)]
fn main() {
fn current_time_step() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() / 30
}
}

Test it:

fn main() {
    let step = current_time_step();
    let remaining = 30 - (std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() % 30);
    println!("Time step: {}", step);
    println!("Next code in: {}s", remaining);
}
# Compare with Python:
python3 -c "import time; print(int(time.time()) // 30)"
# Should match your Rust output

Step 3: HMAC-SHA1

Why HMAC and not encryption?

You might wonder: why not just encrypt(key, time_step) and use the ciphertext as the code?

Option A: Encryption
  encrypt(secret, time_step) → ciphertext
  Problem: ciphertext is REVERSIBLE. If an attacker gets the ciphertext
  AND the time_step (which is just the current time — public!), they
  can try to recover the secret key through chosen-plaintext attacks.

Option B: HMAC (what TOTP uses)
  HMAC(secret, time_step) → tag
  The tag is ONE-WAY. Even if you know the input (time_step) and the
  output (tag), you CANNOT recover the secret key.
  The attacker sees: time_step=57133333, code=847293
  They still can't compute the secret.

The key differences:

                Encryption              HMAC
────────────────────────────────────────────────
Reversible?     Yes (decrypt)           No (one-way)
Output size     Same as input           Fixed (20 bytes for SHA-1)
Goal            Hide data               Prove knowledge of secret
TOTP needs      Prove you have the key  ✓ (this is what we want)
                without revealing it

TOTP doesn’t need to hide the time step (it’s just the current time — everyone knows it). It needs to prove “I know the secret” by producing a value that only someone with the secret could compute. That’s exactly what HMAC does.

Also practical: HMAC output is always 20 bytes regardless of input size, which makes the truncation step simple. Encryption output would vary in size and need padding.

Now, compute the HMAC using the secret and time step (as big-endian u64):

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha1::Sha1;

type HmacSha1 = Hmac<Sha1>;

fn hmac_sha1(secret: &[u8], time_step: u64) -> [u8; 20] {
    let mut mac = HmacSha1::new_from_slice(secret)
        .expect("HMAC accepts any key length");
    mac.update(&time_step.to_be_bytes());  // 8 bytes, big-endian
    let result = mac.finalize().into_bytes();

    let mut output = [0u8; 20];
    output.copy_from_slice(&result);
    output
}
}

Test it:

fn main() {
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    let step = current_time_step();
    let hmac = hmac_sha1(&secret, step);
    println!("HMAC: {}", hex::encode(hmac));  // add `hex` dep, or use {:02x} formatting
    println!("Length: {} bytes (always 20 for SHA-1)", hmac.len());
}

Why big-endian? The RFC specifies it. If you use little-endian, your codes won’t match any other TOTP implementation.

Step 4: Dynamic truncation

This is the clever part — extract a 4-byte chunk from the HMAC at a position determined by the last byte:

#![allow(unused)]
fn main() {
fn truncate(hmac_result: &[u8; 20]) -> u32 {
    // The last nibble (4 bits) determines the offset
    let offset = (hmac_result[19] & 0x0F) as usize;
    // offset is 0-15, and we read 4 bytes, so max index is 15+3=18 (within 20)

    // Extract 4 bytes at that offset, mask the high bit
    u32::from_be_bytes([
        hmac_result[offset] & 0x7F,  // & 0x7F clears the sign bit
        hmac_result[offset + 1],
        hmac_result[offset + 2],
        hmac_result[offset + 3],
    ])
}
}

Test it:

fn main() {
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    let step = current_time_step();
    let hmac = hmac_sha1(&secret, step);

    let offset = (hmac[19] & 0x0F) as usize;
    println!("Last byte: 0x{:02x}", hmac[19]);
    println!("Offset: {} (last nibble)", offset);

    let truncated = truncate(&hmac);
    println!("Truncated: {} (31-bit integer)", truncated);
}

Why & 0x7F? To ensure the result is positive (clear the sign bit). The RFC requires a 31-bit unsigned value.

Step 5: Modulo → 6-digit code

#![allow(unused)]
fn main() {
fn generate_totp(secret_base32: &str, time_step: u64) -> u32 {
    let secret = decode_secret(secret_base32);
    let hmac = hmac_sha1(&secret, time_step);
    let truncated = truncate(&hmac);
    truncated % 1_000_000  // 6 digits
}
}

Test it against the reference:

fn main() {
    let code = generate_totp("JBSWY3DPEHPK3PXP", current_time_step());
    println!("Code: {:06}", code);  // zero-pad to 6 digits
}
# Compare:
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP
oathtool --totp -b "JBSWY3DPEHPK3PXP"
# MUST be identical!

If they don’t match, check:

  1. Is the base32 decoding correct? (Step 1)
  2. Is the time step the same? (Step 2 — clocks might differ by a second across the boundary)
  3. Is the HMAC input big-endian? (Step 3)

Step 6: Wrap it in a nice CLI

fn totp_now(secret_base32: &str) -> u32 {
    generate_totp(secret_base32, current_time_step())
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Generate { secret } => {
            let code = totp_now(&secret);
            let remaining = 30 - (std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_secs() % 30);
            println!("{:06}  (expires in {}s)", code, remaining);
        }
    }
}

Step 7: Validation

Accept current window + previous (for clock skew):

#![allow(unused)]
fn main() {
fn verify_totp(secret: &str, code: u32) -> bool {
    let current_step = current_time_step();
    // Check current and previous window
    for step in [current_step, current_step - 1] {
        if generate_totp(secret, step) == code {
            return true;
        }
    }
    false
}
}

Add to CLI:

#![allow(unused)]
fn main() {
Command::Verify { secret, code } => {
    if verify_totp(&secret, code) {
        println!("Valid!");
    } else {
        println!("Invalid!");
    }
}
}

Test:

# Get current code:
CODE=$(oathtool --totp -b "JBSWY3DPEHPK3PXP")
echo "Code: $CODE"

# Verify immediately:
cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP $CODE
# Valid!

# Wait 60 seconds (two windows), verify again:
sleep 60
cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP $CODE
# Invalid! (code has expired beyond the ±1 window)

You now have a working TOTP authenticator. The exercises below extend it further.

Exercises

Exercise 1: Generate and verify

Implement generate_totp and totp_now. Verify your output matches oathtool:

# Your program:
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP

# Reference:
oathtool --totp -b "JBSWY3DPEHPK3PXP"

# Both should show the same 6-digit code.

Exercise 2: Live display with countdown

Build a CLI that shows the current code with a countdown timer:

$ cargo run -p tls --bin p1-totp -- watch JBSWY3DPEHPK3PXP

  Code: 847293  [████████░░░░░░] 14s remaining

Hint: use \r (carriage return) to overwrite the line. print!("\r Code: {:06} [{}>{}] {}s remaining", code, "█".repeat(filled), "░".repeat(30-filled), remaining).

Exercise 3: Validate a code

Implement verify_totp with a ±1 window. Test:

cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP 847293
# Valid! (if the code is current)

cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP 000000
# Invalid!

Exercise 4: SHA-256 and SHA-512 variants

Extend to support different hash algorithms:

cargo run -p tls --bin p1-totp -- generate --algo sha256 JBSWY3DPEHPK3PXP

Compare: oathtool --totp=sha256 -b "JBSWY3DPEHPK3PXP"

Exercise 5: Generate QR code

Generate an otpauth:// URI and render as a QR code in the terminal (using the qrcode crate):

cargo run -p tls --bin p1-totp -- setup --issuer MyApp --account alice@example.com
# Displays QR code in terminal
# Scan with Google Authenticator
# Verify: codes from your CLI match codes in the app

This is the full setup flow — you’ve built a Google Authenticator clone.

Exercise 6: Import from QR code image

Build a command that reads a QR code image (PNG/JPG), extracts the otpauth:// URI, parses the secret and parameters, and immediately generates a code:

# Someone sends you a QR code screenshot:
cargo run -p tls --bin p1-totp -- import qr-screenshot.png
# Parsed: otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1
# Account: GitHub:alice
# Algorithm: SHA1
# Current code: 847293 (expires in 18s)

Use the image crate to load the image and rqrr to decode the QR code:

[dependencies]
image = "0.25"
rqrr = "0.8"
#![allow(unused)]
fn main() {
use image::GenericImageView;

fn decode_qr(path: &str) -> String {
    let img = image::open(path).unwrap().to_luma8();
    let mut prepared = rqrr::PreparedImage::prepare(img);
    let grids = prepared.detect_grids();
    let (_meta, content) = grids[0].decode().unwrap();
    content  // "otpauth://totp/...?secret=...&algorithm=..."
}

fn parse_otpauth_uri(uri: &str) -> (String, String, String) {
    // Parse: otpauth://totp/Label?secret=XXX&algorithm=SHA1&digits=6&period=30
    // Return: (label, secret, algorithm)
    todo!()
}
}

This is how password managers import TOTP — they scan the QR screenshot and extract the secret.

Test it: take a screenshot of a QR code from any 2FA setup page (or generate one with Exercise 5), save as PNG, and import it.

Note on SHA-1 in TOTP

You might wonder: isn’t SHA-1 broken (Lesson 1)? SHA-1 has known collision attacks — you can find two inputs with the same hash. But HMAC-SHA1 is still secure because HMAC doesn’t rely on collision resistance. It relies on the hash being a pseudorandom function, which SHA-1 still is.

That said, new deployments should prefer SHA-256. The otpauth:// URI supports algorithm=SHA256 and algorithm=SHA512. Most services still default to SHA-1 for compatibility with older authenticator apps.

Project: Signed Git Commits

Prerequisites: Lesson 3 (Ed25519 Signatures). This project applies signing/verification to a real workflow.

What are digital signatures used for?

Digital signatures are everywhere — you interact with them daily without realizing:

┌──────────────────────────────────────────────────────────────┐
│  Where signatures are used in real life                      │
│                                                              │
│  Software updates:                                           │
│    Your phone checks: "Is this update really from Apple?"    │
│    → Apple signed it with their private key                  │
│    → Your phone verifies with Apple's public key             │
│    → Without this, malware could pretend to be an update     │
│                                                              │
│  Package managers (apt, cargo, npm):                         │
│    cargo install ripgrep                                     │
│    → crates.io signs the package metadata                    │
│    → cargo verifies before installing                        │
│    → Without this, a compromised mirror could serve malware  │
│                                                              │
│  Git commits:                                                │
│    git commit -S -m "release v2.0"                           │
│    → Your Ed25519 key signs the commit                       │
│    → GitHub shows a green "Verified" badge                   │
│    → Without this, anyone can forge a commit as "you"        │
│                                                              │
│  PDF / legal documents:                                      │
│    Sign a contract digitally                                 │
│    → Your key proves you agreed to it                        │
│    → The document can't be modified after signing            │
│                                                              │
│  HTTPS certificates (Lesson 7):                              │
│    Server proves its identity during TLS handshake           │
│    → CA signed the server's certificate                      │
│    → Browser verifies the chain                              │
└──────────────────────────────────────────────────────────────┘

What you’re building

A CLI tool that signs files with Ed25519 and produces detached signatures — the same concept behind git commit -S, ssh-keygen -Y sign, and package signing.

# Sign a file:
cargo run -p tls --bin p2-sign -- sign --key my.key document.txt
# Created document.txt.sig

# Verify it:
cargo run -p tls --bin p2-sign -- verify --pubkey my.pub document.txt document.txt.sig
# Signature valid ✓

# Tamper with the file:
echo "extra" >> document.txt
cargo run -p tls --bin p2-sign -- verify --pubkey my.pub document.txt document.txt.sig
# Signature INVALID ✗

How git signing works

Git commits are just text objects. Anyone with write access can create a commit claiming to be “Linus Torvalds”:

# This is trivially easy — no verification:
git -c user.name="Linus Torvalds" -c user.email="torvalds@linux-foundation.org" \
  commit --allow-empty -m "I am definitely Linus"

git log -1
# Author: Linus Torvalds <torvalds@linux-foundation.org>   ← fake!

Signed commits fix this:

┌──────────────────────────────────────────────────────┐
│  Normal git commit:                                  │
│    commit message + tree hash + author + timestamp   │
│    → stored as a git object                          │
│    → ANYONE can forge the author field               │
│                                                      │
│  Signed git commit (git commit -S):                  │
│    same data + Ed25519 signature                     │
│    → signature proves the author has the private key │
│    → GitHub shows "Verified" badge                   │
│    → forging would require stealing the private key  │
└──────────────────────────────────────────────────────┘

Try it with existing tools first

Before building our own, let’s see how the real tools work:

# === SSH signatures (the modern approach) ===

# Generate a key if you don't have one:
ssh-keygen -t ed25519 -f sign_key -N ""
# Creates sign_key (private) and sign_key.pub (public)

# Sign a file:
echo "important document" > doc.txt
ssh-keygen -Y sign -f sign_key -n file doc.txt
# Creates doc.txt.sig

# Look at the signature:
cat doc.txt.sig
# -----BEGIN SSH SIGNATURE-----
# U1NIU0lHAAAAAQA... (base64-encoded)
# -----END SSH SIGNATURE-----

# To verify, SSH needs an "allowed signers" file (like a trust store):
echo "user@example.com $(cat sign_key.pub)" > allowed_signers

# Verify:
ssh-keygen -Y verify -f allowed_signers -I user@example.com -n file -s doc.txt.sig < doc.txt
# Good "file" signature for user@example.com

# Tamper and verify again:
echo "tampered" >> doc.txt
ssh-keygen -Y verify -f allowed_signers -I user@example.com -n file -s doc.txt.sig < doc.txt
# Could not verify signature — FAILED!
# === Set up git commit signing ===

# Tell git to use SSH for signing:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub

# Sign a commit:
git commit -S -m "this commit is signed"

# Verify:
git log --show-signature -1
# Good "git" signature for user@example.com

# On GitHub: push the commit → see the green "Verified" badge
# (You need to upload your public key to GitHub → Settings → SSH and GPG keys)
# === OpenSSL signatures ===

openssl genpkey -algorithm Ed25519 -out sign.key
openssl pkey -in sign.key -pubout -out sign.pub

echo "document content" > doc.txt
openssl pkeyutl -sign -inkey sign.key -in doc.txt -out doc.sig
openssl pkeyutl -verify -pubin -inkey sign.pub -in doc.txt -sigfile doc.sig
# Signature Verified Successfully

# The .sig file is raw bytes (64 bytes for Ed25519):
wc -c doc.sig
# 64 doc.sig

xxd doc.sig | head -3
# Raw signature bytes — not human-readable

The signing flow

Sign:
  ┌────────────┐     ┌──────────────┐     ┌────────────┐
  │ file bytes │ ──► │ Ed25519 sign │ ──► │ .sig file  │
  │            │     │ (private key)│     │ (64 bytes) │
  └────────────┘     └──────────────┘     └────────────┘

  The signature is "detached" — it's a separate file.
  The original file is NOT modified.

Verify:
  ┌────────────┐
  │ file bytes │──┐
  └────────────┘  │   ┌──────────────┐
                  ├──►│Ed25519 verify│──► ✓ or ✗
  ┌────────────┐  │   │ (public key) │
  │ .sig file  │──┘   └──────────────┘
  └────────────┘

  You need: the file, the signature, AND the public key.
  If ANY of the three is wrong, verification fails.

Implementation guide

Step 0: Project setup

touch tls/src/bin/p2-sign.rs

Dependencies (already in tls/Cargo.toml):

ed25519-dalek = { version = "2", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
clap = { version = "4", features = ["derive"] }
hex = "0.4"

Start with a CLI skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "sign-tool", about = "Sign and verify files with Ed25519")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Generate a new Ed25519 key pair
    Keygen { key_path: String },
    /// Sign a file
    Sign {
        #[arg(long)]
        key: String,
        file: String,
    },
    /// Verify a file's signature
    Verify {
        #[arg(long)]
        pubkey: String,
        file: String,
        signature: String,
    },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Keygen { key_path } => todo!(),
        Command::Sign { key, file } => todo!(),
        Command::Verify { pubkey, file, signature } => todo!(),
    }
}
cargo run -p tls --bin p2-sign -- --help
# Should show the three subcommands

Step 1: Key generation

Generate an Ed25519 key pair and save to disk:

#![allow(unused)]
fn main() {
use ed25519_dalek::SigningKey;

fn generate_keypair(key_path: &str) {
    let signing_key = SigningKey::generate(&mut rand_core::OsRng);
    let public_key = signing_key.verifying_key();

    // Save private key (raw 32 bytes)
    std::fs::write(key_path, signing_key.to_bytes()).unwrap();
    // Save public key (raw 32 bytes)
    std::fs::write(format!("{key_path}.pub"), public_key.to_bytes()).unwrap();

    println!("Private key saved to: {key_path}");
    println!("Public key saved to:  {key_path}.pub");
    println!("Public key (hex):     {}", hex::encode(public_key.to_bytes()));
}
}

Test it:

cargo run -p tls --bin p2-sign -- keygen my.key
# Private key saved to: my.key
# Public key saved to:  my.key.pub
# Public key (hex):     a1b2c3d4...

# Verify the files exist and have the right size:
ls -la my.key my.key.pub
# my.key     32 bytes (private key)
# my.key.pub 32 bytes (public key)

Security note: in a real tool, you’d encrypt the private key with a password (like SSH does). Here we store it raw for simplicity.

Step 2: Signing a file

Read the file, sign its contents, write the signature to a .sig file:

#![allow(unused)]
fn main() {
use ed25519_dalek::{SigningKey, Signer};

fn sign_file(key_path: &str, file_path: &str) {
    // Load private key
    let key_bytes: [u8; 32] = std::fs::read(key_path)
        .expect("can't read key file")
        .try_into()
        .expect("key file must be exactly 32 bytes");
    let signing_key = SigningKey::from_bytes(&key_bytes);

    // Read the file to sign
    let file_data = std::fs::read(file_path)
        .expect("can't read file to sign");

    // Sign
    let signature = signing_key.sign(&file_data);

    // Write signature
    let sig_path = format!("{file_path}.sig");
    std::fs::write(&sig_path, signature.to_bytes()).unwrap();

    println!("Signed: {file_path}");
    println!("Signature: {sig_path} ({} bytes)", signature.to_bytes().len());
    println!("Signature (hex): {}", hex::encode(signature.to_bytes()));
}
}

Test it:

# Create a test file:
echo "Hello, this is an important document." > doc.txt

# Sign it:
cargo run -p tls --bin p2-sign -- sign --key my.key doc.txt
# Signed: doc.txt
# Signature: doc.txt.sig (64 bytes)

# Look at the signature:
xxd doc.txt.sig | head -3
# Raw bytes — 64 bytes of Ed25519 signature

# Compare with OpenSSL:
openssl genpkey -algorithm Ed25519 -out openssl.key
openssl pkeyutl -sign -inkey openssl.key -in doc.txt -out doc.openssl.sig
wc -c doc.txt.sig doc.openssl.sig
# Both are 64 bytes — same algorithm, same output size

Step 3: Verification

Read the file, the signature, and the public key. Verify:

#![allow(unused)]
fn main() {
use ed25519_dalek::{VerifyingKey, Verifier, Signature};

fn verify_file(pubkey_path: &str, file_path: &str, sig_path: &str) {
    // Load public key
    let pub_bytes: [u8; 32] = std::fs::read(pubkey_path)
        .expect("can't read public key")
        .try_into()
        .expect("public key must be exactly 32 bytes");
    let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
        .expect("invalid public key");

    // Read the file
    let file_data = std::fs::read(file_path)
        .expect("can't read file");

    // Read the signature
    let sig_bytes: [u8; 64] = std::fs::read(sig_path)
        .expect("can't read signature file")
        .try_into()
        .expect("signature must be exactly 64 bytes");
    let signature = Signature::from_bytes(&sig_bytes);

    // Verify
    match verifying_key.verify_strict(&file_data, &signature) {
        Ok(()) => {
            println!("✓ Signature valid");
            println!("  File: {file_path}");
            println!("  Signed by: {}", hex::encode(pub_bytes));
        }
        Err(e) => {
            println!("✗ Signature INVALID");
            println!("  File: {file_path}");
            println!("  Error: {e}");
            std::process::exit(1);
        }
    }
}
}

Test it — the moment of truth:

# Verify the good signature:
cargo run -p tls --bin p2-sign -- verify --pubkey my.key.pub doc.txt doc.txt.sig
# ✓ Signature valid

# Now tamper with the file:
echo " sneaky modification" >> doc.txt
cargo run -p tls --bin p2-sign -- verify --pubkey my.key.pub doc.txt doc.txt.sig
# ✗ Signature INVALID

# Restore and verify again:
echo "Hello, this is an important document." > doc.txt
cargo run -p tls --bin p2-sign -- verify --pubkey my.key.pub doc.txt doc.txt.sig
# ✓ Signature valid

# Try with the WRONG public key:
cargo run -p tls --bin p2-sign -- keygen other.key
cargo run -p tls --bin p2-sign -- verify --pubkey other.key.pub doc.txt doc.txt.sig
# ✗ Signature INVALID — wrong key, even though file is untouched

Three things must match: the file, the signature, and the public key. Change any one → verification fails.

Step 4: Put it all together

Wire the functions into the CLI match arms:

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Keygen { key_path } => generate_keypair(&key_path),
        Command::Sign { key, file } => sign_file(&key, &file),
        Command::Verify { pubkey, file, signature } => verify_file(&pubkey, &file, &signature),
    }
}

The complete tool in ~60 lines of logic. That’s the beauty of Ed25519 — tiny keys, tiny signatures, simple API.

Real-world scenario: software release

Let’s walk through a complete release signing workflow:

# === You are the developer ===

# 1. Generate your release signing key (do this ONCE, keep it safe):
cargo run -p tls --bin p2-sign -- keygen release.key
# Public key: a1b2c3d4...  ← publish this on your website

# 2. Build your software:
cargo build --release
cp target/release/my-app ./my-app-v2.0

# 3. Sign the release:
cargo run -p tls --bin p2-sign -- sign --key release.key my-app-v2.0
# Creates my-app-v2.0.sig

# 4. Upload both to your download page:
#    my-app-v2.0     (the binary)
#    my-app-v2.0.sig (the signature)
#    release.key.pub (your public key — on your website)

# === A user downloads your software ===

# 5. User downloads the binary + signature + your public key
# 6. User verifies:
cargo run -p tls --bin p2-sign -- verify \
  --pubkey release.key.pub my-app-v2.0 my-app-v2.0.sig
# ✓ Signature valid — safe to install!

# === An attacker compromises the download mirror ===

# 7. Attacker replaces my-app-v2.0 with malware
# 8. User verifies:
cargo run -p tls --bin p2-sign -- verify \
  --pubkey release.key.pub my-app-v2.0 my-app-v2.0.sig
# ✗ Signature INVALID — don't install!

Exercises

Exercise 1: Sign and verify CLI

Build the complete CLI as described above. Test all three failure cases:

  1. Tampered file → invalid
  2. Wrong public key → invalid
  3. Correct file + correct key → valid

Exercise 2: Sign multiple files (manifest)

Sign an entire directory. Create a manifest that maps filenames to signatures:

cargo run -p tls --bin p2-sign -- sign-dir --key my.key ./release/
# Signed 3 files:
#   README.md   → README.md.sig
#   main.rs     → main.rs.sig
#   Cargo.toml  → Cargo.toml.sig
# Manifest written to: ./release/MANIFEST.sig

cargo run -p tls --bin p2-sign -- verify-dir --pubkey my.key.pub ./release/
# ✓ README.md   — valid
# ✓ main.rs     — valid
# ✗ Cargo.toml  — INVALID (was modified!)

Exercise 3: Timestamped signatures

Include the current timestamp in the signed data: sign(key, timestamp_bytes || file_data). The signature covers both the time and the content.

The .sig file contains both the timestamp and the signature — bundled together so they can’t be tampered with independently:

┌──────────────────────────────────┐
│  .sig file layout (72 bytes):   │
│                                  │
│  bytes 0-7:   timestamp          │  u64 big-endian, Unix seconds
│  bytes 8-71:  Ed25519 signature  │  64 bytes
│                                  │
│  The signature covers:           │
│    timestamp_bytes || file_bytes │
│                                  │
│  Why not a separate timestamp    │
│  file? Because an attacker could │
│  swap the timestamp while keeping│
│  the signature — bundling them   │
│  means both are protected.       │
└──────────────────────────────────┘

Signing:

#![allow(unused)]
fn main() {
fn sign_with_timestamp(key: &SigningKey, file_data: &[u8]) -> Vec<u8> {
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();

    // Sign: timestamp || file_data
    let mut signed_data = Vec::new();
    signed_data.extend_from_slice(&timestamp.to_be_bytes());
    signed_data.extend_from_slice(file_data);
    let signature = key.sign(&signed_data);

    // .sig file: timestamp (8 bytes) + signature (64 bytes)
    let mut sig_file = Vec::new();
    sig_file.extend_from_slice(&timestamp.to_be_bytes());
    sig_file.extend_from_slice(&signature.to_bytes());
    sig_file  // 72 bytes total
}
}

Verification:

#![allow(unused)]
fn main() {
fn verify_with_timestamp(pubkey: &VerifyingKey, file_data: &[u8],
                          sig_file: &[u8], max_age_secs: u64) -> Result<(), String> {
    // Extract timestamp + signature from .sig file
    let timestamp = u64::from_be_bytes(sig_file[..8].try_into().unwrap());
    let signature = Signature::from_bytes(&sig_file[8..72].try_into().unwrap());

    // Reconstruct signed data
    let mut signed_data = Vec::new();
    signed_data.extend_from_slice(&timestamp.to_be_bytes());
    signed_data.extend_from_slice(file_data);

    // Verify signature
    pubkey.verify_strict(&signed_data, &signature)
        .map_err(|_| "signature invalid".to_string())?;

    // Check timestamp
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();
    if timestamp > now { return Err("timestamp is in the future".into()); }
    if now - timestamp > max_age_secs { return Err(format!("signature expired ({} seconds old)", now - timestamp)); }

    Ok(())
}
}

Test:

cargo run -p tls --bin p2-sign -- sign --key my.key --timestamp doc.txt
# Signed at: 2026-04-11 10:30:00 UTC

# Verify immediately:
cargo run -p tls --bin p2-sign -- verify --pubkey my.key.pub --max-age 24h doc.txt doc.txt.sig
# ✓ Signature valid (signed 2 minutes ago)

# The .sig file is now 72 bytes (8 timestamp + 64 signature):
wc -c doc.txt.sig
# 72

# Wait 25 hours...
cargo run -p tls --bin p2-sign -- verify --pubkey my.key.pub --max-age 24h doc.txt doc.txt.sig
# ✗ Signature expired (signed 25 hours ago)

Exercise 4: Cross-verify with ssh-keygen

This is advanced: make your .sig file format compatible with SSH signatures so ssh-keygen -Y verify can check your Rust-generated signatures. Read the SSH signature format spec — it wraps the raw Ed25519 signature in a structured envelope with namespace and hash algorithm fields.

Lesson 5: HMAC and Key Derivation (HKDF)

Alice’s Bookstore — Chapter 5

Alice and her customer just agreed on a shared secret using Diffie-Hellman (Lesson 4). Alice is about to use it as the encryption key when Bob stops her:

“Wait. You can’t use that one secret for everything. If you use the same key to encrypt data going TO the customer and data coming FROM the customer, an attacker could reflect your own messages back to you.”

“So I need… two keys? From one secret?”

“Exactly. And in real TLS, you’ll need even more — one for the handshake, one for each direction of data, one for session resumption. All from the same shared secret.”

“How do you get multiple keys from one secret?”

“You run it through a key derivation function. Think of it like a locksmith who cuts many different keys from one master.”

Real-life analogy: the master key and the key cutter

A building manager has one master key (the DH shared secret). From it, a locksmith cuts separate keys for each door:

Master key (DH shared secret)
    │
    ├── cut "office" → Office key     (client → server encryption key)
    ├── cut "storage" → Storage key   (server → client encryption key)
    ├── cut "garage" → Garage key     (handshake encryption key)
    └── cut "mailbox" → Mailbox key   (resumption secret)

Each key opens ONLY its door.
Losing the office key doesn't compromise the storage room.
The locksmith (HKDF) makes this possible.

The problem

In Lesson 4, you got a 32-byte shared secret via DH. But you need multiple independent keys:

  • One key for client → server encryption
  • One key for server → client encryption
  • (In real TLS: also keys for IVs, handshake encryption, resumption, etc.)

You can’t reuse the same key for both directions. If you do, an attacker can reflect your own encrypted messages back to you and you’d accept them as valid.

Also, the raw DH shared secret has mathematical structure — it’s a point on an elliptic curve, not uniformly random bytes. You want to “clean it up” into proper key material.

HMAC: Hash-based Message Authentication Code

Before HKDF, you need to understand HMAC. It combines a hash function with a secret key:

HMAC(key, message) = Hash((key ⊕ opad) || Hash((key ⊕ ipad) || message))

In plain English: hash the message with the key mixed in, twice.

Plain hash (anyone can compute):       HMAC (needs the secret key):
┌────────────────────────────────┐     ┌────────────────────────────────┐
│ SHA-256("transfer $100")       │     │ HMAC(secret, "transfer $100") │
│ = a1b2c3...                    │     │ = x7y8z9...                    │
│                                │     │                                │
│ Attacker can compute this!     │     │ Attacker can't compute this!  │
│ Can forge checksums.           │     │ Can't forge the tag.           │
└────────────────────────────────┘     └────────────────────────────────┘

The result is a fixed-size tag that proves:

  1. Integrity: the message wasn’t modified
  2. Authenticity: only someone with the key could produce this tag

HKDF: Extract and Expand

HKDF uses HMAC to derive keys in two steps:

Step 1: Extract

PRK = HKDF-Extract(salt, input_key_material)
    = HMAC(salt, shared_secret)

Takes the raw DH output (which may have non-uniform randomness) and concentrates the entropy into a pseudorandom key (PRK). The salt is optional — even an empty salt works.

Step 2: Expand

key_1 = HKDF-Expand(PRK, info="client-to-server", length=32)
key_2 = HKDF-Expand(PRK, info="server-to-client", length=32)

Takes the PRK and stretches it into multiple independent keys. The info parameter is a label — same PRK with different labels produces completely unrelated keys. You can generate as many keys as you need.

Visualizing the whole flow

DH shared secret (32 bytes, non-uniform)
        │
        ▼
┌─────────────────────────────────┐
│  HKDF-Extract(salt, secret)     │  "concentrate the entropy"
│  = HMAC(salt, secret)           │
└───────────────┬─────────────────┘
                │
                ▼
         PRK (32 bytes, uniformly random)
                │
      ┌─────────┼─────────┐
      │         │         │
      ▼         ▼         ▼
  Expand    Expand    Expand
  "c2s"     "s2c"     "iv"
      │         │         │
      ▼         ▼         ▼
  key_1     key_2     key_3    (all independent)

Try it yourself

# HMAC with OpenSSL:
echo -n "hello" | openssl dgst -sha256 -hmac "mysecretkey"
# HMAC-SHA256 tag — try changing the message or key, output changes completely

# Compare with plain hash (no key):
echo -n "hello" | openssl dgst -sha256
# Anyone can compute this — no secret involved
# HKDF with Python (the openssl CLI doesn't support HKDF directly):
python3 -c "
import hmac, hashlib

# Step 1: Extract
secret = bytes.fromhex('0102030405060708090a0b0c0d0e0f10')
salt = b'my-salt'
prk = hmac.new(salt, secret, hashlib.sha256).digest()
print(f'PRK: {prk.hex()[:32]}...')

# Step 2: Expand (simplified, one block)
import struct
info_c2s = b'client-to-server'
info_s2c = b'server-to-client'
key_c2s = hmac.new(prk, info_c2s + struct.pack('B', 1), hashlib.sha256).digest()
key_s2c = hmac.new(prk, info_s2c + struct.pack('B', 1), hashlib.sha256).digest()
print(f'c2s key: {key_c2s.hex()[:32]}...')
print(f's2c key: {key_s2c.hex()[:32]}...')
print(f'Different? {key_c2s != key_s2c}')
"
# See HKDF in a real TLS connection (requires Wireshark + TLS key log):
# Set SSLKEYLOGFILE to capture TLS secrets:
SSLKEYLOGFILE=/tmp/keys.log curl -s https://example.com > /dev/null
cat /tmp/keys.log
# You'll see lines like:
# CLIENT_HANDSHAKE_TRAFFIC_SECRET ...
# SERVER_HANDSHAKE_TRAFFIC_SECRET ...
# CLIENT_TRAFFIC_SECRET_0 ...
# These are all derived via HKDF from the DH shared secret!

Real-world scenarios

Alice and Bob derive session keys

Continuing from Lesson 4: Alice and Bob have a shared DH secret.

  1. Both compute: PRK = HKDF-Extract(salt="tls13", shared_secret)
  2. Alice derives: c2s_key = HKDF-Expand(PRK, "client-to-server", 32)
  3. Alice derives: s2c_key = HKDF-Expand(PRK, "server-to-client", 32)
  4. Bob derives the exact same two keys (same PRK, same labels)
  5. Alice encrypts messages TO Bob with c2s_key
  6. Alice decrypts messages FROM Bob with s2c_key
  7. Bob does the reverse

Even though both keys came from one shared secret, they’re cryptographically independent. Compromising c2s_key doesn’t reveal s2c_key.

TLS 1.3 key schedule

TLS 1.3 uses HKDF extensively. The key schedule derives dozens of keys from the DH shared secret:

DH shared secret
  │
  ├─ HKDF → handshake_secret
  │           ├─ HKDF → client_handshake_key (encrypts ClientFinished)
  │           └─ HKDF → server_handshake_key (encrypts ServerFinished)
  │
  └─ HKDF → master_secret
              ├─ HKDF → client_application_key (encrypts app data c→s)
              ├─ HKDF → server_application_key (encrypts app data s→c)
              └─ HKDF → resumption_secret (for session resumption)

Each key has a unique label, so they’re all independent. If one key leaks, the others remain secure.

API token derivation

A web service needs to generate unique API tokens for each user from a master secret:

master = random 32 bytes (stored securely on server)
token_alice = HKDF-Expand(master, "user:alice", 32)
token_bob   = HKDF-Expand(master, "user:bob", 32)

Each token is unique and unpredictable, but the server only stores one master secret. If Alice’s token is compromised, Bob’s is safe — they’re independent.

HMAC vs plain hash: why it matters

Imagine Alice sends Bob a message with a checksum: ("transfer $100", SHA-256("transfer $100")). Eve intercepts it, computes SHA-256("transfer $999"), and replaces the checksum. Bob sees a valid checksum and processes the transfer.

With HMAC: Alice sends ("transfer $100", HMAC(shared_key, "transfer $100")). Eve can’t forge the HMAC without the shared key. She can’t even verify her forgery. Bob checks the HMAC → forgery is detected.

Exercises

Exercise 1: Derive two keys (implemented in 5-kdf.rs)

Take a shared secret, use HKDF to derive two 32-byte keys with different info strings. Print both — they must be different.

Exercise 2: Deterministic derivation

Run the program twice with the same hardcoded shared secret and salt. Verify you get the exact same derived keys both times. This is critical — both sides of a TLS connection must derive identical keys independently.

Exercise 3: HMAC verification

Use the hmac crate to compute HMAC-SHA256(key, message). Then verify it:

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;

let mut mac = HmacSha256::new_from_slice(key)?;
mac.update(message);
let tag = mac.finalize().into_bytes();

// Verify
let mut mac = HmacSha256::new_from_slice(key)?;
mac.update(message);
mac.verify_slice(&tag)?; // Constant-time comparison!
}

Note: verify_slice uses constant-time comparison to prevent timing attacks. Never use == to compare MACs.

Exercise 4: Timing attack awareness

Compare MACs using == vs constant-time comparison. Time both with a correct MAC and a MAC that differs only in the last byte. With ==, the wrong-last-byte MAC takes slightly less time (short-circuits). With verify_slice, both take the same time.

This is why hmac crate’s verify_slice matters — an attacker measuring response times can guess the correct MAC byte by byte.

Exercise 5: Full pipeline

Combine Lessons 4 + 5: do a DH key exchange, then derive two keys via HKDF, then encrypt a message with c2s_key (Lesson 2) and decrypt with the same key on the “other side”. This is the core of what Lesson 9 will build over TCP.

Lesson 6: Password-Based KDFs (PBKDF2/Argon2)

Alice’s Bookstore — Chapter 6

Alice’s bookstore now has customer accounts. Customers create passwords to log in. One night, Alice gets an email from her hosting provider:

“We detected a breach. An attacker accessed your database.”

Alice panics. She stored passwords using SHA-256 — that’s a hash, not plaintext, so it should be safe… right? Bob shakes his head:

“SHA-256 is too fast. An attacker with a GPU can try 10 billion passwords per second. Most of your customers use common passwords — they’ll be cracked in minutes.”

“But I hashed them! Isn’t that enough?”

“Fast hashing is great when you WANT speed — like in HKDF. But for passwords, speed is the enemy. You need a hash that’s intentionally slow.”

Prerequisites: Lesson 5 (HKDF). You understand key derivation — now learn why passwords need a different approach.

The core problem: passwords are terrible secrets

In Lesson 5, we used HKDF to derive encryption keys from a DH shared secret. That shared secret was 32 random bytes — impossible to guess.

But what if the secret is a password? This happens more often than you’d think:

When the user IS the key:
  Password manager    → master password → decrypt vault
  Encrypted disk      → login password → decrypt drive (FileVault, LUKS, BitLocker)
  Encrypted ZIP/7z    → password → decrypt files
  SSH key passphrase  → passphrase → decrypt private key
  Encrypted backups   → password → decrypt backup (restic, borgbackup)
  Crypto wallet       → password → decrypt wallet file
  E2E encrypted notes → password → decrypt your notes (Standard Notes)
  KeePass             → master password → decrypt password database

In all these cases, there’s no DH key exchange, no pre-shared random secret. Just a human’s password. And passwords are:

A random 256-bit key:
  7f3a9b2c...  (32 bytes of pure randomness)
  Possible values: 2^256 ≈ 10^77
  Time to brute-force: longer than the universe exists

A typical password:
  "password123"
  Possible values: maybe 10 billion common passwords
  Time to brute-force with HKDF: seconds

Feel the problem yourself — run this:

python3 -c "
import hashlib, time

# Simulate an attacker trying passwords with fast hashing
passwords = ['password', '123456', 'qwerty', 'letmein', 'monkey',
             'dragon', 'master', 'abc123', 'password123', 'target']

target_hash = hashlib.sha256(b'password123').hexdigest()

start = time.time()
for pw in passwords:
    h = hashlib.sha256(pw.encode()).hexdigest()
    if h == target_hash:
        elapsed = time.time() - start
        print(f'CRACKED! Password is: {pw}')
        print(f'Time: {elapsed*1000:.3f}ms')
        print(f'Tried {passwords.index(pw)+1} passwords')
        break

print()
print('Now imagine trying 10 billion passwords instead of 10.')
print('At 100 million hashes/sec (one GPU), that takes 100 seconds.')
print('Your password is cracked in under 2 minutes.')
"

That’s the problem. HKDF and SHA-256 are too fast. An attacker with a GPU can try billions of passwords per second.

The solution: make it slow on purpose

The idea is simple:

  Fast hash (SHA-256):
    password → key              instantly
    Attacker: 1,000,000,000 attempts/second

  Slow hash (PBKDF2, 100K iterations):
    password → hash → hash → ... (100K times) → key
    Attacker: 10 attempts/second

  Memory-hard hash (Argon2):
    password → fill 64MB of RAM with data → key
    Attacker: needs 64MB PER attempt
    GPU with 1000 cores but only 8GB RAM?
    → only 125 parallel attempts, not 1000

Real-life analogy: the vault door

Regular door lock (HKDF):
  Insert key → door opens instantly
  If someone has a lockpick (GPU), they try 1 billion keys/second
  ┌──┐
  │🚪│ → click → open
  └──┘

Bank vault door (PBKDF2):
  Insert key → wait 1 second → door opens
  Lockpick still works, but only 1 attempt/second
  ┌──┐
  │🚪│ → click → ⏰ 1 second → open
  └──┘

Vault door + weight requirement (Argon2):
  Insert key → must also carry 64kg weight → wait → door opens
  Lockpick works, but you need one 64kg weight per attempt
  Can't try 1000 doors in parallel unless you have 64,000 kg of weights
  ┌──┐
  │🚪│ → click → 🏋️ 64kg → ⏰ 1 second → open
  └──┘

The delay and memory cost don’t bother legitimate users (one login = one derivation). They devastate attackers who need billions of attempts.

See the difference yourself

# FAST: SHA-256 — how many per second?
python3 -c "
import hashlib, time
start = time.time()
for i in range(1_000_000):
    hashlib.sha256(b'password123').digest()
elapsed = time.time() - start
rate = 1_000_000 / elapsed
print(f'SHA-256:   {rate:,.0f} hashes/second')
print(f'  10 billion passwords cracked in: {10_000_000_000/rate:.0f} seconds')
"

# SLOW: PBKDF2 100K iterations
python3 -c "
import hashlib, os, time
salt = os.urandom(16)
start = time.time()
for i in range(10):
    hashlib.pbkdf2_hmac('sha256', b'password123', salt, 100_000)
elapsed = time.time() - start
rate = 10 / elapsed
print(f'PBKDF2:    {rate:,.1f} hashes/second')
print(f'  10 billion passwords cracked in: {10_000_000_000/rate/3600/24/365:.0f} years')
"

Run both. The difference is dramatic — millions per second vs a handful per second.

What does a password KDF actually do?

All password KDFs do the same thing conceptually:

Input:  "password123" (weak, guessable)
Output: 7f3a9b2c4d... (32 bytes, looks random, usable as encryption key)

Same as HKDF — but intentionally slow. The slowness IS the security feature.

Think of it this way:

  • HKDF is a machine that instantly turns a secret into a key
  • A password KDF is the same machine, but with a speed limiter bolted on
  • The speed limiter wastes CPU time (PBKDF2) or RAM (Argon2) on purpose
  • A legitimate user calls it once (1 second delay — barely noticeable)
  • An attacker calls it billions of times (1 second × 1 billion = 31 years)

The three password KDFs

PBKDF2 (Password-Based Key Derivation Function 2)

The oldest, simplest. Runs HMAC in a loop:

PBKDF2(password, salt, iterations) → key

Internally:
  round 1: HMAC(password, salt || 1)     → h1
  round 2: HMAC(password, h1)            → h2
  round 3: HMAC(password, h2)            → h3
  ...
  round 100000: HMAC(password, h99999)   → h100000
  key = h1 XOR h2 XOR h3 XOR ... XOR h100000

Each iteration is cheap individually, but 100,000 of them add up. The problem: GPUs run HMAC very fast in parallel. PBKDF2 is CPU-bound but not memory-bound.

bcrypt

Designed specifically for password hashing. Uses a modified Blowfish cipher internally. Harder to parallelize on GPUs than PBKDF2, but fixed output size (not a general-purpose KDF).

Argon2 (the modern choice)

Winner of the 2015 Password Hashing Competition. Three parameters:

Argon2(password, salt, time_cost, memory_cost, parallelism) → key

  time_cost:    number of iterations (like PBKDF2)
  memory_cost:  MB of RAM required per computation
  parallelism:  number of threads to use

Example:
  Argon2id("password123", salt, t=3, m=65536, p=4)
  → requires 64MB RAM and 4 threads for ~1 second

The memory requirement is what makes Argon2 special. GPUs have limited per-core memory — requiring 64MB per attempt makes GPU attacks 1000x slower.

┌──────────────────────────────────────────────────────┐
│  Password KDF Comparison                             │
│                                                      │
│  Algorithm   CPU Cost    Memory Cost    GPU Resistant │
│  ─────────────────────────────────────────────────── │
│  PBKDF2      High        None           No           │
│  bcrypt      High        4KB            Somewhat     │
│  Argon2id    High        Configurable   Yes          │
│                                                      │
│  For new projects: always use Argon2id.              │
│  PBKDF2 is acceptable if Argon2 isn't available.     │
│  bcrypt for password hashing (not key derivation).   │
└──────────────────────────────────────────────────────┘

Salt: preventing rainbow tables

A salt is random data added to the password before hashing. Without salt, two users with the same password get the same hash — an attacker can precompute a table of “password → hash” pairs (rainbow table).

Without salt:
  hash("password123") → 0xabc...
  hash("password123") → 0xabc...  ← same! rainbow table works

With salt (unique per user):
  hash("password123" + salt_alice) → 0x123...
  hash("password123" + salt_bob)   → 0x789...  ← different!
  Rainbow table is useless — would need a separate table per salt.

Salt should be:

  • Random: 16+ bytes from a CSPRNG
  • Unique: different for every user / every encryption
  • Stored alongside the hash: it’s not secret, just unique

Try it yourself

# Generate a random salt
openssl rand -hex 16

# PBKDF2 with OpenSSL
echo -n "mypassword" | openssl kdf -keylen 32 -kdfopt digest:SHA256 \
  -kdfopt pass:mypassword -kdfopt salt:$(openssl rand -hex 16) \
  -kdfopt iter:100000 PBKDF2 | xxd

# Or use Python to see PBKDF2 in action:
python3 -c "
import hashlib, os, time

password = b'password123'
salt = os.urandom(16)

start = time.time()
key = hashlib.pbkdf2_hmac('sha256', password, salt, 100_000)
elapsed = time.time() - start

print(f'Salt:     {salt.hex()}')
print(f'Key:      {key.hex()}')
print(f'Time:     {elapsed:.3f}s')
print(f'Rate:     {1/elapsed:.0f} attempts/second')
print()
print(f'With 10B passwords to try: {10_000_000_000 * elapsed / 3600:.0f} hours')
"
# Compare with fast hashing (SHA-256):
python3 -c "
import hashlib, time

password = b'password123'
start = time.time()
for _ in range(1_000_000):
    hashlib.sha256(password).digest()
elapsed = time.time() - start

print(f'SHA-256: {1_000_000/elapsed:.0f} hashes/second')
print('That is why you never use plain SHA-256 for passwords.')
"

PBKDF2 in Rust

#![allow(unused)]
fn main() {
use pbkdf2::pbkdf2_hmac;
use sha2::Sha256;

let password = b"my secret password";
let salt = b"random-salt-here"; // in real code: use rand to generate
let iterations = 100_000;
let mut key = [0u8; 32];

pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut key);
println!("Derived key: {}", hex::encode(key));
}

Argon2 in Rust

#![allow(unused)]
fn main() {
use argon2::Argon2;

let password = b"my secret password";
let salt = b"random-salt-here";
let mut key = [0u8; 32];

let argon2 = Argon2::default(); // Argon2id, t=3, m=64MB, p=4
argon2.hash_password_into(password, salt, &mut key).unwrap();
println!("Derived key: {}", hex::encode(key));
}

When to use which

Deriving a key from a DH shared secret → HKDF (Lesson 5)
  Fast, the input is already strong.

Deriving a key from a user's password → Argon2id
  Slow on purpose, the input is weak.

Storing a password hash in a database → Argon2id or bcrypt
  You don't need the key, just a hash to verify against.

API key / token derivation → HKDF
  The input (master secret) is already random.

Exercises

Exercise 1: PBKDF2 key derivation

Add pbkdf2 and sha2 to your dependencies. Derive a 32-byte key from a password with 100,000 iterations. Print the key as hex. Verify that the same password + salt always gives the same key.

Exercise 2: Timing comparison

Time how long PBKDF2 takes with 1,000 vs 10,000 vs 100,000 vs 1,000,000 iterations. Plot the results. How many passwords/second can an attacker try at each level?

Exercise 3: Argon2 key derivation

Add argon2 to your dependencies. Derive a key with default parameters. Then try:

  • t_cost = 1, m_cost = 16384 (16MB) — fast
  • t_cost = 3, m_cost = 65536 (64MB) — default
  • t_cost = 10, m_cost = 262144 (256MB) — paranoid

Measure time and observe memory usage with ps -o rss -p <pid>.

Exercise 4: Salt uniqueness

Derive keys from the same password with two different salts. Verify the keys are completely different. Then derive from the same password + same salt twice — keys should match. This proves salt prevents precomputation.

Exercise 5: Encrypt a file with a password

Combine this lesson with Lesson 2:

  1. Ask for a password
  2. Generate a random salt
  3. Derive a key with Argon2
  4. Encrypt a file with ChaCha20-Poly1305 using that key
  5. Store salt || nonce || ciphertext to disk
  6. Decrypt: read salt, ask for password, derive key, decrypt

This is the foundation of the Password Manager Vault project.

Project: Password Manager Vault

Prerequisites: Lesson 2 (ChaCha20-Poly1305), Lesson 6 (Password-Based KDFs). This project directly applies what you just learned.

What is a password vault?

Every time you create an account on a website, you need a unique, strong password. Nobody can remember 200 random passwords. A password manager solves this:

┌──────────────────────────────────────────────────────────┐
│  The problem:                                            │
│                                                          │
│  github.com      → need a password                       │
│  gmail.com       → need a different password             │
│  bank.com        → need another different password       │
│  ... × 200 sites                                         │
│                                                          │
│  Reusing passwords? One breach exposes all accounts.     │
│  Writing them down? Paper can be stolen/lost.            │
│                                                          │
│  The solution: password manager                          │
│                                                          │
│  One master password → unlocks a vault                   │
│  Vault contains all your passwords, encrypted            │
│  The vault file is useless without the master password   │
└──────────────────────────────────────────────────────────┘

This is how KeePass, 1Password, Bitwarden, and LastPass work. You’re building a simplified version.

What you’re building

A CLI password manager. One file on disk, encrypted with your master password:

# Create a new vault:
cargo run -p tls --bin p3-vault -- init
# Enter master password: ********
# Created vault.enc

# Store a password:
cargo run -p tls --bin p3-vault -- add github
# Enter master password: ********
# Username: alice
# Password: s3cret!@#456
# Saved.

# Retrieve it later:
cargo run -p tls --bin p3-vault -- get github
# Enter master password: ********
# Username: alice
# Password: s3cret!@#456

# List all entries:
cargo run -p tls --bin p3-vault -- list
# Enter master password: ********
# github, gmail, ssh-server

# Wrong password:
cargo run -p tls --bin p3-vault -- list
# Enter master password: wrong-password
# Error: wrong password or corrupted vault

How it works — the big picture

Two lessons combine:

Lesson 6 (Password KDF):          Lesson 2 (Encryption):
  master password → Argon2 → key     key + vault → encrypt → ciphertext

Together:
  "correct horse battery staple"
          │
          ▼
  ┌──────────────────────┐
  │ Argon2id             │   Slow on purpose (Lesson 6)
  │ password + salt → key│   Attacker can't brute-force
  └──────────┬───────────┘
             │
             ▼ 32-byte key
  ┌──────────────────────┐
  │ ChaCha20-Poly1305    │   AEAD encryption (Lesson 2)
  │ key + nonce + data   │   Confidentiality + integrity
  │ → ciphertext + tag   │
  └──────────┬───────────┘
             │
             ▼
  ┌──────────────────────┐
  │ vault.enc on disk    │   Random bytes without the password
  │ salt | nonce | cipher│
  └──────────────────────┘

The vault file format

The .enc file is a simple binary format:

┌────────────┬────────────┬──────────────────────────────┐
│ Salt       │ Nonce      │ Encrypted JSON + auth tag    │
│ 16 bytes   │ 12 bytes   │ variable length              │
└────────────┴────────────┴──────────────────────────────┘

Salt:   random, generated once when vault is created
        Used by Argon2 to derive the key
        Not secret — just prevents rainbow tables

Nonce:  random, generated fresh every time the vault is saved
        Used by ChaCha20 for encryption
        Ensures re-saving with the same password produces different ciphertext

The JSON inside (after decryption):
{
  "entries": {
    "github": { "username": "alice", "password": "s3cret!@#456" },
    "gmail":  { "username": "alice@gmail.com", "password": "Tr0ub4d0r&3" }
  }
}

Try it with existing tools first

# See how openssl encrypts a file with a password (uses PBKDF2 internally):
echo '{"github": {"user": "alice", "pass": "s3cret"}}' > vault.json
openssl enc -aes-256-cbc -salt -pbkdf2 -in vault.json -out vault.enc
# enter password

# Decrypt:
openssl enc -aes-256-cbc -d -salt -pbkdf2 -in vault.enc
# enter same password → JSON appears

# Wrong password:
openssl enc -aes-256-cbc -d -salt -pbkdf2 -in vault.enc
# enter wrong password → "bad decrypt" error

# Look at the encrypted file — random bytes:
xxd vault.enc | head -5

rm vault.json vault.enc

That’s what we’re building in Rust — but with Argon2 (stronger than PBKDF2) and ChaCha20-Poly1305 (modern AEAD).

Implementation guide

Step 0: Project setup

touch tls/src/bin/p3-vault.rs

Dependencies (add to tls/Cargo.toml):

argon2 = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rpassword = "7"  # for reading passwords without echoing to terminal

CLI skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "vault", about = "Encrypted password vault")]
struct Cli {
    #[command(subcommand)]
    command: Command,

    /// Path to the vault file
    #[arg(long, default_value = "vault.enc")]
    vault: String,
}

#[derive(Subcommand)]
enum Command {
    /// Create a new empty vault
    Init,
    /// Add a new entry
    Add { name: String },
    /// Retrieve an entry
    Get { name: String },
    /// List all entry names
    List,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Init => todo!(),
        Command::Add { name } => todo!(),
        Command::Get { name } => todo!(),
        Command::List => todo!(),
    }
}
cargo run -p tls --bin p3-vault -- --help

Step 1: Read password from terminal (without echoing)

When you type a password, it shouldn’t appear on screen:

#![allow(unused)]
fn main() {
fn ask_password(prompt: &str) -> String {
    rpassword::prompt_password(prompt).unwrap()
}

fn ask_input(prompt: &str) -> String {
    eprint!("{prompt}");
    let mut input = String::new();
    std::io::stdin().read_line(&mut input).unwrap();
    input.trim().to_string()
}
}

Test it:

fn main() {
    let password = ask_password("Master password: ");
    println!("You typed {} characters (not shown)", password.len());
}
cargo run -p tls --bin p3-vault -- init
# Master password: (you type, nothing appears)
# You typed 12 characters (not shown)

Step 2: Derive encryption key from password

This is Lesson 6 in action:

#![allow(unused)]
fn main() {
use argon2::Argon2;

fn derive_key(password: &[u8], salt: &[u8; 16]) -> [u8; 32] {
    let mut key = [0u8; 32];
    Argon2::default()
        .hash_password_into(password, salt, &mut key)
        .unwrap();
    key
}
}

Test it:

fn main() {
    let salt: [u8; 16] = rand::random();
    let key = derive_key(b"my-password", &salt);
    println!("Salt: {}", hex::encode(salt));
    println!("Key:  {}", hex::encode(key));

    // Same password + same salt = same key (deterministic):
    let key2 = derive_key(b"my-password", &salt);
    assert_eq!(key, key2);
    println!("Deterministic: ✓");

    // Different password = different key:
    let key3 = derive_key(b"wrong-password", &salt);
    assert_ne!(key, key3);
    println!("Different password = different key: ✓");
}

Step 3: Encrypt and decrypt the vault

This is Lesson 2 in action:

#![allow(unused)]
fn main() {
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Aead, Nonce, Key};

fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> ([u8; 12], Vec<u8>) {
    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
    let nonce_bytes: [u8; 12] = rand::random();
    let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
        .expect("encryption failed");
    (nonce_bytes, ciphertext)
}

fn decrypt(key: &[u8; 32], nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>, String> {
    let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
    cipher.decrypt(Nonce::from_slice(nonce), ciphertext)
        .map_err(|_| "Wrong password or corrupted vault".to_string())
}
}

Key insight: when the password is wrong, Argon2 produces a different key, and ChaCha20’s AEAD tag verification fails. The user sees “wrong password” — not garbled data. AEAD gives us this for free.

Step 4: Save and load the vault file

#![allow(unused)]
fn main() {
fn save_vault(path: &str, salt: &[u8; 16], nonce: &[u8; 12], ciphertext: &[u8]) {
    let mut file_data = Vec::with_capacity(16 + 12 + ciphertext.len());
    file_data.extend_from_slice(salt);       // bytes 0-15
    file_data.extend_from_slice(nonce);      // bytes 16-27
    file_data.extend_from_slice(ciphertext); // bytes 28+
    std::fs::write(path, &file_data).unwrap();
}

fn load_vault(path: &str) -> ([u8; 16], [u8; 12], Vec<u8>) {
    let data = std::fs::read(path).expect("Can't read vault file");
    assert!(data.len() >= 28, "Vault file too small — corrupted?");

    let salt: [u8; 16] = data[..16].try_into().unwrap();
    let nonce: [u8; 12] = data[16..28].try_into().unwrap();
    let ciphertext = data[28..].to_vec();
    (salt, nonce, ciphertext)
}
}

Test the round-trip:

cargo run -p tls --bin p3-vault -- init
# Master password: ********
# Created vault.enc

ls -la vault.enc
# 44 bytes (16 salt + 12 nonce + 2 JSON "{}" + 16 auth tag = 46... close)

xxd vault.enc | head -3
# Random-looking bytes — vault is encrypted

Step 5: The vault data structure

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Default)]
struct Vault {
    entries: HashMap<String, Entry>,
}

#[derive(Serialize, Deserialize)]
struct Entry {
    username: String,
    password: String,
}
}

Step 6: Wire it all together

#![allow(unused)]
fn main() {
/// Open an existing vault (ask password, decrypt, parse JSON)
fn open_vault(path: &str) -> (Vault, [u8; 16]) {
    let password = ask_password("Master password: ");
    let (salt, nonce, ciphertext) = load_vault(path);
    let key = derive_key(password.as_bytes(), &salt);
    let plaintext = decrypt(&key, &nonce, &ciphertext)
        .expect("Wrong password or corrupted vault");
    let vault: Vault = serde_json::from_slice(&plaintext).unwrap();
    (vault, salt)
}

/// Save vault (re-encrypt with same salt, fresh nonce)
fn save(path: &str, vault: &Vault, salt: &[u8; 16], password: &str) {
    let key = derive_key(password.as_bytes(), salt);
    let json = serde_json::to_vec(vault).unwrap();
    let (nonce, ciphertext) = encrypt(&key, &json);
    save_vault(path, salt, &nonce, &ciphertext);
}
}

Now implement each command:

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Init => {
            let password = ask_password("Master password: ");
            let confirm = ask_password("Confirm password: ");
            if password != confirm {
                eprintln!("Passwords don't match!");
                return;
            }
            let salt: [u8; 16] = rand::random();
            let vault = Vault::default();
            save(&cli.vault, &vault, &salt, &password);
            println!("Created {}", cli.vault);
        }
        Command::Add { name } => {
            let password = ask_password("Master password: ");
            let (mut vault, salt) = open_vault(&cli.vault);
            let username = ask_input("Username: ");
            let entry_password = ask_input("Password: ");
            vault.entries.insert(name.clone(), Entry { username, password: entry_password });
            save(&cli.vault, &vault, &salt, &password);
            println!("Saved entry: {name}");
        }
        Command::Get { name } => {
            let (vault, _) = open_vault(&cli.vault);
            match vault.entries.get(&name) {
                Some(entry) => {
                    println!("Username: {}", entry.username);
                    println!("Password: {}", entry.password);
                }
                None => eprintln!("Entry '{name}' not found"),
            }
        }
        Command::List => {
            let (vault, _) = open_vault(&cli.vault);
            for name in vault.entries.keys() {
                println!("{name}");
            }
        }
    }
}

Step 7: Test it end-to-end

# Create vault:
cargo run -p tls --bin p3-vault -- init
# Master password: test123
# Confirm password: test123
# Created vault.enc

# Add entries:
cargo run -p tls --bin p3-vault -- add github
# Master password: test123
# Username: alice
# Password: gh-s3cret!

cargo run -p tls --bin p3-vault -- add gmail
# Master password: test123
# Username: alice@gmail.com
# Password: gm-p@ssw0rd

# List:
cargo run -p tls --bin p3-vault -- list
# Master password: test123
# github
# gmail

# Get:
cargo run -p tls --bin p3-vault -- get github
# Master password: test123
# Username: alice
# Password: gh-s3cret!

# Wrong password:
cargo run -p tls --bin p3-vault -- list
# Master password: wrongpassword
# Error: Wrong password or corrupted vault

# Inspect the file — just random bytes:
xxd vault.enc | head -5

Security considerations

What this vault gets RIGHT:
  ✓ Argon2id: brute-forcing the master password is impractical
  ✓ Random salt: prevents rainbow table attacks
  ✓ Fresh nonce per save: re-saving doesn't reuse nonces
  ✓ AEAD tag: wrong password → clean error, not garbled data
  ✓ AEAD tag: tampered file → clean error, not corrupted data

What a REAL password manager also does:
  ✗ Zeroize keys in memory after use (zeroize crate)
  ✗ Lock the vault after a timeout
  ✗ Clipboard integration (copy password, auto-clear after 30s)
  ✗ Browser extension for auto-fill
  ✗ Sync across devices (Bitwarden uses a server, KeePass uses file sync)
  ✗ Backup/recovery (what if you forget the master password?)

Exercises

Exercise 1: Basic vault

Implement all the steps above. Test with the commands shown in Step 7.

Exercise 2: Password generator

Add a generate command that creates random passwords:

cargo run -p tls --bin p3-vault -- generate --length 20 --symbols
# Generated: k9$mP2@xL5#nQ8wR3&jY

# Or generate and save in one step:
cargo run -p tls --bin p3-vault -- add github --generate --length 16
# Master password: ********
# Username: alice
# Generated password: xK7mN2pQ9rW4tY6a
# Saved.

Exercise 3: Export / import

Add export (decrypt → JSON file) and import (JSON file → encrypted vault):

cargo run -p tls --bin p3-vault -- export --out backup.json
# Master password: ********
# Exported 5 entries to backup.json (PLAINTEXT — delete after use!)

cargo run -p tls --bin p3-vault -- import --in backup.json
# New master password: ********
# Imported 5 entries.

Exercise 4: Zeroize sensitive data

The master password and derived key sit in memory while the program runs. Use the zeroize crate to wipe them immediately after use:

#![allow(unused)]
fn main() {
use zeroize::Zeroize;

let mut password = ask_password("Master password: ");
let mut key = derive_key(password.as_bytes(), &salt);
// ... use key ...
key.zeroize();       // [0, 0, 0, 0, ... 0]
password.zeroize();  // ""
// Memory no longer contains sensitive data
}

Why this matters: if the process crashes or is swapped to disk, the password/key could be recovered from a memory dump.

Lesson 7: Certificates and Trust (X.509)

Alice’s Bookstore — Chapter 7

Alice has encryption, signatures, and key exchange working. Her bookstore seems secure. Then Bob asks her a hard question:

“When a customer visits your site, how do they know it’s really YOUR site? Mallory could set up a fake ‘Alice’s Bookstore’ at a similar domain, do a DH key exchange with the customer, and steal their credit card. Encryption doesn’t help if you’re encrypting to the wrong person.”

“But I can sign things with my key — they can verify it’s me!”

“How does the customer get your public key in the first place? If Mallory intercepts that too, she gives the customer HER public key pretending it’s yours. You need a trusted third party — someone the customer already trusts — to vouch for your identity.”

“Like a… passport office?”

“Exactly. In the internet world, they’re called Certificate Authorities.”

Real-life analogy: the passport

You arrive at a foreign border. How does the officer know you are who you claim to be?

┌──────────────────────────────────────────────────────────┐
│  You:     "I'm Alice from the US"                        │
│  Officer: "Prove it"                                     │
│  You:     (shows passport)                               │
│                                                          │
│  Passport:                         Certificate:          │
│    Name: Alice Smith                 Subject: example.com│
│    Photo: (your face)                Public key: 0x3a8f..│
│    Issued by: US Government          Issuer: Let's Encrypt│
│    Expires: 2030-01-01              Expires: 2025-01-01 │
│    Signature: (government stamp)     Signature: (CA's)   │
│                                                          │
│  Officer checks:                   Browser checks:       │
│    1. Is the issuer trusted?         1. Trusted CA?       │
│    2. Is the signature valid?        2. Valid signature?  │
│    3. Has it expired?                3. Expired?          │
│    4. Does the photo match?          4. Hostname match?   │
└──────────────────────────────────────────────────────────┘

A certificate IS a digital passport. It binds an identity (domain name) to a public key, signed by a trusted authority.

The missing piece

You can now exchange keys (Lesson 4), derive encryption keys (Lesson 5), and encrypt data (Lesson 2). But there’s a fatal flaw: how does the client know it’s talking to the real server?

The man-in-the-middle attack

Without authentication, an attacker (Mallory) sits between Alice and Bob:

Alice ←──DH──→ Mallory ←──DH──→ Bob
       key_1             key_2
  • Alice thinks she did DH with Bob. She actually did DH with Mallory → key_1
  • Bob thinks he did DH with Alice. He actually did DH with Mallory → key_2
  • Mallory decrypts Alice’s messages with key_1, reads them, re-encrypts with key_2, sends to Bob
  • Neither Alice nor Bob detects anything wrong

All the encryption in the world doesn’t help if you’re encrypting to the wrong person.

Certificates: binding identity to public keys

A certificate is a signed document that says:

┌──────────────────────────────────────┐
│ X.509 Certificate                    │
│                                      │
│ Subject:    server.example.com       │
│ Public Key: 0x3a8f7b...             │
│ Issuer:     Let's Encrypt            │
│ Valid:      2024-01-01 to 2025-01-01 │
│ Serial:     12345                    │
│                                      │
│ Signature:  0xab12... (signed by     │
│             issuer's private key)    │
└──────────────────────────────────────┘

The issuer (Certificate Authority) vouches: “I verified that the entity controlling server.example.com holds the private key corresponding to public key 0x3a8f7b....”

Chain of trust

Who vouches for the CA? Another CA, all the way up to a Root CA:

Root CA (pre-installed on your OS — Apple, Google, Mozilla maintain these lists)
  │
  └─ signs → Intermediate CA certificate (e.g., Let's Encrypt R3)
               │
               └─ signs → Server certificate (e.g., example.com)

Your browser/OS ships with ~150 trusted root CA certificates. When a server presents its certificate:

  1. Read the server certificate → signed by Intermediate CA
  2. Read the Intermediate CA certificate → signed by Root CA
  3. Root CA is in the trusted store → chain is valid
  4. Verify the server certificate’s subject matches the hostname you’re connecting to

If any link breaks — wrong signature, expired cert, hostname mismatch — the connection is rejected.

See it yourself

# View a real website's certificate chain:
echo | openssl s_client -connect google.com:443 -showcerts 2>/dev/null | \
  grep -E "subject=|issuer="
# subject=CN = *.google.com
# issuer=CN = GTS CA 1C3           ← intermediate CA
# subject=CN = GTS CA 1C3
# issuer=CN = GTS Root R1          ← root CA

# See the full certificate details:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -text -noout | head -30

# Check when it expires:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -dates
# notBefore=...
# notAfter=...

# See the Subject Alternative Names (what domains this cert covers):
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -ext subjectAltName
# DNS:*.google.com, DNS:google.com, DNS:*.youtube.com, ...
# See which Root CAs your OS trusts:
# macOS:
security find-certificate -a /System/Library/Keychains/SystemRootCertificates.keychain | \
  grep "alis" | wc -l
# ~150 trusted root certificates

# Linux:
ls /etc/ssl/certs/ | wc -l
# Or:
awk -v cmd='openssl x509 -noout -subject' '/BEGIN/{close(cmd)};{print | cmd}' \
  /etc/ssl/certs/ca-certificates.crt 2>/dev/null | wc -l
# Generate a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout server.key -out server.crt \
  -days 365 -subj "/CN=localhost"

# Inspect it:
openssl x509 -in server.crt -text -noout | head -20
# Note: Issuer == Subject (self-signed)

Self-signed certificates

A self-signed certificate signs itself — it’s both the subject and the issuer. No chain of trust; the client must explicitly trust it.

Used for:

  • Development and testing
  • Internal infrastructure (company VPNs, private services)
  • Scenarios where you control both client and server

This is analogous to WireGuard: you manually exchange public keys rather than using a CA hierarchy.

Real-world scenarios

Alice visits her bank’s website

  1. Alice navigates to https://bank.com
  2. Bank’s server sends its certificate: “I am bank.com, here’s my public key, signed by DigiCert”
  3. Alice’s browser checks:
    • Is DigiCert’s certificate in the trusted root store? Yes
    • Does DigiCert’s signature on bank.com’s certificate verify? Yes
    • Is the certificate still valid (not expired)? Yes
    • Does the subject match the URL? bank.com == bank.com Yes
  4. Browser proceeds with TLS handshake using the server’s public key
  5. The padlock icon appears

If Mallory tries to MITM this, she can’t forge a certificate for bank.com — she doesn’t have DigiCert’s private key. She could present her own self-signed certificate, but the browser would show a scary warning.

Bob deploys a private service

Bob runs an internal API at api.internal.corp. He doesn’t want to (or can’t) use a public CA.

  1. Bob generates a self-signed CA certificate (his own private root)
  2. Bob generates a server certificate for api.internal.corp, signs it with his CA
  3. Bob installs his CA certificate on all client machines
  4. Clients trust api.internal.corp because it chains to Bob’s CA

This is common in corporate environments, Kubernetes clusters, and development setups.

Certificate pinning (extra security)

Instead of trusting any CA to vouch for a server, the client hardcodes the expected certificate (or public key hash). Even if a CA is compromised, the attacker can’t forge a pin-matching certificate.

Used by: banking apps, Signal, some browsers for critical services (Google pins its own certs in Chrome).

The Let’s Encrypt revolution

Before 2015, certificates cost money ($50-300/year) and required manual verification. Let’s Encrypt automated the process:

  1. You prove you control a domain (by placing a file on your web server or adding a DNS record)
  2. Let’s Encrypt issues a free certificate, valid for 90 days
  3. Automated renewal via certbot

This made HTTPS the default for the entire web. Over 80% of web traffic is now encrypted, up from ~30% in 2014.

Certificate formats

  • PEM: Base64-encoded, delimited by -----BEGIN CERTIFICATE-----. Human-readable, used by most tools.
  • DER: Raw binary encoding. Same data as PEM, just not base64-encoded.
  • PKCS#12 / PFX: Bundles certificate + private key in one encrypted file. Common on Windows.

Exercises

Exercise 1: Parse a certificate

Generate a self-signed cert with openssl, read it in Rust using rustls-pemfile + x509-parser, print subject and public key algorithm.

# Generate:
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout server.key -out server.crt -days 365 -subj "/CN=localhost"

# Your Rust program should output:
# Subject: CN=localhost
# Public key: rsaEncryption

Exercise 2: Certificate details

Extend the parser to print:

  • Issuer name (should equal Subject for self-signed)
  • Validity dates (not before / not after)
  • Serial number
  • Signature algorithm
  • Subject Alternative Names (if any)

Compare your output with openssl x509 -in server.crt -text -noout.

Exercise 3: Verify the self-signature

For a self-signed cert, the issuer’s public key = subject’s public key. Extract both and verify the signature. The x509-parser crate’s verify_signature method can help.

Exercise 4: Download and parse a real certificate chain

# Download google.com's chain:
echo | openssl s_client -connect google.com:443 -showcerts 2>/dev/null > chain.pem

Parse each certificate in the PEM file. Print subject/issuer for each. You should see the chain: *.google.com → GTS CA 1C3 → GTS Root R1.

Exercise 5: Certificate expiry checker

Build a CLI tool that connects to a list of domains, downloads their certificates, and reports days until expiry:

cargo run -p tls --bin 7-certs -- check google.com github.com expired.badssl.com
# google.com:  expires in 62 days  ✓
# github.com:  expires in 198 days ✓
# expired.badssl.com: EXPIRED 847 days ago  ✗

The site expired.badssl.com has an intentionally expired cert — great for testing.

Lesson 8: Certificate Generation (rcgen)

Alice’s Bookstore — Chapter 8

Alice’s bookstore is thriving. She now runs 12 microservices: inventory, payments, shipping, notifications, and more. Each one needs its own TLS certificate.

“I can’t run openssl 12 times every time I deploy. And when I add a new service, I have to manually generate a cert, copy it to the right server… it’s a nightmare.”

Bob: “You need to generate certificates from code. Your deployment script creates a cert automatically when a new service starts. No manual steps.”

“But can I just… make my own certificates? Don’t I need Let’s Encrypt?”

“For internal services — services that talk to each other, not to the public internet — you create your own CA and issue your own certs. You’re the passport office for your own company.”

Prerequisites: Lesson 7 (Certificates & Trust). You understand what certificates contain and how trust chains work. Now create them in code.

Why generate certificates in code?

In Lesson 7, you used openssl on the command line to create certificates. That works for a one-time setup. But what about:

Situations where you can't use the openssl CLI:
  
  Integration tests:
    "I need fresh certs every time tests run"
    → can't ask developers to run openssl manually before each test
  
  Dynamic services:
    "A new microservice spins up and needs a cert immediately"
    → can't wait for a human to run openssl
  
  The intercepting proxy (Project P11):
    "Client connects to google.com — I need a fake cert for google.com NOW"
    → must generate a cert in milliseconds, for any domain, on the fly
  
  Embedded devices:
    "IoT device boots up and needs a unique cert"
    → no openssl installed on the device
  
  CI/CD pipelines:
    "Build server needs TLS certs for staging environment"
    → must be automated, no manual steps

In all these cases, you need to generate certificates programmatically — from your Rust code, not from a terminal.

Real-life analogy: printing your own ID badges

Lesson 7 (openssl CLI):
  You went to the government office.
  You waited in line.
  An officer printed your passport.
  One passport at a time, manual process.

This lesson (rcgen):
  You bought a badge printer for your company.
  Your software prints employee badges automatically.
  New employee joins → badge printed in seconds.
  No waiting, no manual work, any name/department.

  ┌────────────────────┐     ┌──────────────────────────┐
  │  Government office │     │  Your badge printer      │
  │  (openssl CLI)     │     │  (rcgen in Rust)         │
  │                    │     │                          │
  │  Manual            │     │  Automatic               │
  │  One at a time     │     │  Any quantity             │
  │  Slow              │     │  Instant                  │
  │  External tool     │     │  Part of your program     │
  └────────────────────┘     └──────────────────────────┘

What we’re building

A Rust program that creates certificates — the same certificates that openssl creates, but from code:

#![allow(unused)]
fn main() {
// One line to create a self-signed cert:
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()])?;
// That's it. Same result as: openssl req -x509 -newkey rsa:2048 ...
}

Two types of certificate

There are only two types, and the difference is one flag:

CA certificate (is_ca = true):
  "I am allowed to sign OTHER certificates"
  Like a notary — can stamp other documents
  Usually self-signed (signs itself)
  Installed in trust stores

Server certificate (is_ca = false):
  "I am a specific server (e.g., localhost)"
  Like an employee badge — identifies one entity
  Signed BY a CA
  Presented during TLS handshake

That’s the entire difference. A CA cert has is_ca = true, which means it can sign other certs. A server cert has is_ca = false, which means it can’t.

The certificate hierarchy

┌──────────────────────────────────────────┐
│  Root CA Certificate (self-signed)       │
│  Subject: "My Root CA"                   │
│  Issuer:  "My Root CA" (same = self)     │
│  is_ca:   TRUE ← can sign other certs   │
│  Key:     CA key pair                    │
└─────────────────┬────────────────────────┘
                  │ signs (using CA's private key)
                  ▼
┌──────────────────────────────────────────┐
│  Server Certificate                      │
│  Subject: "localhost"                    │
│  Issuer:  "My Root CA" ← who signed me  │
│  is_ca:   FALSE ← cannot sign other certs│
│  Key:     server key pair (different!)   │
│  SANs:    localhost, 127.0.0.1           │
└──────────────────────────────────────────┘

The CA and server have DIFFERENT key pairs.
The CA signs the server cert with the CA's private key.
Clients verify the signature with the CA's public key.

rcgen basics

#![allow(unused)]
fn main() {
use rcgen::generate_simple_self_signed;

// Simplest: self-signed cert for localhost
let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()];
let cert = generate_simple_self_signed(subject_alt_names)?;

// Get the PEM-encoded certificate and key
let cert_pem = cert.cert.pem();
let key_pem = cert.key_pair.serialize_pem();

println!("Certificate:\n{}", cert_pem);
println!("Private key:\n{}", key_pem);
}

Building a CA

A CA certificate needs special flags:

#![allow(unused)]
fn main() {
use rcgen::{CertificateParams, IsCa, BasicConstraints, KeyPair};

let mut ca_params = CertificateParams::new(vec!["My Root CA".to_string()])?;
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);

let ca_key = KeyPair::generate()?;
let ca_cert = ca_params.self_signed(&ca_key)?;

println!("CA cert:\n{}", ca_cert.pem());
}

Signing a server certificate with the CA

#![allow(unused)]
fn main() {
let mut server_params = CertificateParams::new(vec![
    "localhost".to_string(),
    "127.0.0.1".to_string(),
])?;
server_params.is_ca = IsCa::NoCa;

let server_key = KeyPair::generate()?;
let server_cert = server_params.signed_by(&server_key, &ca_cert, &ca_key)?;

println!("Server cert (signed by CA):\n{}", server_cert.pem());
}

How trust works (the full chain)

Step 1: You generate a CA cert + server cert (this lesson)

Step 2: You install the CA cert on the CLIENT machine
  (add to browser trust store, or load in rustls root store)

Step 3: Client connects to server over TLS:

  Server sends:     "Here's my cert: localhost, signed by My Root CA"
                          │
  Client asks:      "Do I trust My Root CA?"
                          │
  Client checks     ┌─────▼─────────────────────────┐
  trust store:      │ Trusted CAs:                   │
                    │   DigiCert         ← no        │
                    │   Let's Encrypt    ← no        │
                    │   My Root CA       ← YES! ✓    │
                    └────────────────────────────────┘
                          │
  Client verifies:  Is the signature on the server cert valid?
                    (verify using My Root CA's public key)
                          │
                          ✓ → connection trusted

Without Step 2 (installing the CA cert), the client would reject the connection — it doesn’t know “My Root CA”.

Try it yourself

# Inspect an rcgen-generated cert (save PEM output to cert.pem first):
openssl x509 -in cert.pem -text -noout | head -20

# Verify a certificate chain:
openssl verify -CAfile ca.pem server.pem
# Should print: server.pem: OK

# See the chain relationship:
openssl x509 -in server.pem -text -noout | grep -A2 "Issuer"
openssl x509 -in ca.pem -text -noout | grep -A2 "Subject"
# Issuer of server.pem should match Subject of ca.pem

Why do we pass domain names to CertificateParams::new()?

You might have noticed: when we created certificates above, we passed domain names like "localhost" and "127.0.0.1" to CertificateParams::new(). What does rcgen do with them?

It puts them in the Subject Alternative Names (SANs) field — the list of domains and IPs this certificate is valid for. When a browser connects to https://localhost, it checks the SANs (not the Subject/CN field) to verify the hostname matches.

Old way (deprecated):
  Subject: CN=localhost          ← browsers used to check this
  SANs: (empty)

Modern way (what rcgen does):
  Subject: CN=localhost          ← mostly ignored by browsers now
  SANs: DNS:localhost, IP:127.0.0.1  ← THIS is what browsers check

If your cert’s SANs don’t include the hostname you’re connecting to, the browser rejects it — even if the CN matches. That’s why we pass the domain names when creating the cert.

A single cert can cover multiple domains:

#![allow(unused)]
fn main() {
// One cert for all your domains:
let params = CertificateParams::new(vec![
    "example.com".into(),
    "www.example.com".into(),
    "api.example.com".into(),
    "127.0.0.1".into(),
])?;
// rcgen automatically creates SANs for all of these
}
# See SANs of a real website — Google's cert covers dozens of domains:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -ext subjectAltName
# DNS:*.google.com, DNS:google.com, DNS:*.youtube.com, ...

When you need this

  • Development: generate self-signed certs for local HTTPS
  • Testing: create CA + server certs for integration tests
  • Internal infrastructure: issue certs for microservices
  • The intercepting proxy: generate certs on-the-fly for any domain

Exercises

Exercise 1: Self-signed certificate

Use rcgen::generate_simple_self_signed to create a cert for localhost. Save the PEM to a file. Inspect it with openssl x509 -in cert.pem -text -noout.

Exercise 2: CA + server certificate

  1. Generate a CA certificate (with is_ca = IsCa::Ca(...))
  2. Generate a server certificate for “localhost”
  3. Sign the server cert with the CA
  4. Verify: openssl verify -CAfile ca.pem server.pem

Exercise 3: Use with tokio-rustls

Take the CA + server cert from Exercise 2:

  1. Server: load server cert + key into rustls::ServerConfig
  2. Client: load CA cert into rustls::ClientConfig root store
  3. Connect — the handshake should succeed
  4. Change the server cert’s SAN to “notlocalhost” — handshake should fail

Exercise 4: Certificate inspector

Connect to a real website. After the TLS handshake, extract the peer’s certificate chain. Parse each with x509-parser and print: subject, issuer, SANs, validity. Compare with openssl s_client -connect <host>:443.

Project: Certificate Inspector

Prerequisites: Lesson 7 (Certificates), Lesson 14 (tokio-rustls). Connect to real websites and inspect their TLS certificates.

Why inspect certificates?

Certificates are the backbone of internet trust. Being able to inspect them is a fundamental skill:

┌──────────────────────────────────────────────────────────────┐
│  When you need to inspect certificates                       │
│                                                              │
│  DevOps / SRE:                                               │
│    "Our HTTPS is broken" → is the cert expired?              │
│    "Users see a warning" → hostname mismatch? wrong chain?   │
│    "Cert renew failed"   → what's the current expiry?        │
│                                                              │
│  Security:                                                   │
│    "Is this site legit?" → who issued the cert? trusted CA?  │
│    "MITM detection"      → did the cert fingerprint change?  │
│    "CT monitoring"       → was a cert issued for my domain?  │
│                                                              │
│  Development:                                                │
│    "mTLS isn't working"  → is the client cert valid?         │
│    "Self-signed setup"   → are SANs configured correctly?    │
│    "Testing TLS code"    → what does the server actually send?│
└──────────────────────────────────────────────────────────────┘

What you’re building

A CLI tool that connects to any website, downloads its certificate chain, and shows everything — like a mini openssl s_client but with cleaner output.

cargo run -p tls --bin p3-cert-inspector -- google.com

  google.com:443
  ──────────────
  Protocol:   TLS 1.3
  Cipher:     TLS_AES_256_GCM_SHA384

  Certificate chain:
    [0] *.google.com
        Issuer:     GTS CA 1C3
        Valid:      2024-10-21 to 2025-01-13
        Expires in: 42 days
        Key:        ECDSA (P-256)
        SANs:       *.google.com, google.com, *.youtube.com, ...

    [1] GTS CA 1C3
        Issuer:     GTS Root R1
        Valid:      2020-08-13 to 2027-09-30
        Key:        RSA (2048 bits)

  Fingerprint (SHA-256): a1b2c3d4e5f6...

The reference tools

Before building our own, see what the standard tools show:

# === openssl s_client — the classic ===

# Full connection info:
echo | openssl s_client -connect google.com:443 2>/dev/null | head -25
# CONNECTED(00000003)
# depth=2 ...
# depth=1 ...
# depth=0 ...
# ---
# Certificate chain
#  0 s:CN = *.google.com
#    i:CN = GTS CA 1C3
#  1 s:CN = GTS CA 1C3
#    i:CN = GTS Root R1

# Just the certificate details:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -text -noout | head -30

# Just the dates:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -dates
# notBefore=Oct 21 08:22:04 2024 GMT
# notAfter=Jan 13 08:22:03 2025 GMT

# Just the SANs:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -ext subjectAltName

# Just the fingerprint:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -fingerprint -sha256
# === Test sites with intentional cert problems ===

# Expired cert:
echo | openssl s_client -connect expired.badssl.com:443 2>/dev/null | \
  openssl x509 -noout -dates
# notAfter is in the past!

# Wrong hostname:
echo | openssl s_client -connect wrong.host.badssl.com:443 2>/dev/null | \
  openssl x509 -noout -subject -ext subjectAltName
# Subject doesn't match the hostname

# Self-signed:
echo | openssl s_client -connect self-signed.badssl.com:443 2>/dev/null | head -5
# "verify error:num=18:self-signed certificate"

Implementation guide

Step 0: Project setup

touch tls/src/bin/p3-cert-inspector.rs

Add to tls/Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "0.26"
x509-parser = "0.16"
clap = { version = "4", features = ["derive"] }

CLI skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "cert-inspector", about = "Inspect TLS certificates of any website")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Inspect a site's certificate chain
    Inspect {
        /// Domain name (e.g., google.com)
        host: String,
        /// Port (default: 443)
        #[arg(long, default_value = "443")]
        port: u16,
    },
    /// Check certificate expiry for multiple domains
    CheckExpiry {
        /// Domain names
        hosts: Vec<String>,
    },
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Inspect { host, port } => todo!(),
        Command::CheckExpiry { hosts } => todo!(),
    }
}

Step 1: Connect over TLS

#![allow(unused)]
fn main() {
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use rustls::{ClientConfig, RootCertStore};

async fn tls_connect(host: &str, port: u16)
    -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error>>
{
    // Load the system's trusted root CAs
    let mut root_store = RootCertStore::empty();
    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());

    let config = ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_no_client_auth();

    let connector = TlsConnector::from(Arc::new(config));
    let tcp = TcpStream::connect(format!("{host}:{port}")).await?;
    let server_name = host.try_into()?;
    let tls = connector.connect(server_name, tcp).await?;
    Ok(tls)
}
}

Test it:

#[tokio::main]
async fn main() {
    let tls = tls_connect("google.com", 443).await.unwrap();
    println!("Connected to google.com over TLS!");

    // Extract negotiated parameters:
    let (_, conn) = tls.get_ref();
    println!("Protocol: {:?}", conn.protocol_version().unwrap());
    println!("Cipher:   {:?}", conn.negotiated_cipher_suite().unwrap());
}
cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# Connected to google.com over TLS!
# Protocol: TLSv1_3
# Cipher: TLS13_AES_256_GCM_SHA384

Step 2: Extract the certificate chain

After the TLS handshake, the peer’s certificates are available:

#![allow(unused)]
fn main() {
let (_, conn) = tls.get_ref();
let certs = conn.peer_certificates()
    .expect("server didn't send certificates");

println!("Certificate chain: {} certificates", certs.len());
}

Each cert is DER-encoded bytes. Let’s parse them.

Step 3: Parse certificates with x509-parser

#![allow(unused)]
fn main() {
use x509_parser::prelude::*;

fn print_cert(index: usize, der: &[u8]) {
    let (_, cert) = X509Certificate::from_der(der)
        .expect("failed to parse certificate");

    println!("  [{}] {}", index, cert.subject());
    println!("      Issuer:  {}", cert.issuer());
    println!("      Valid:   {} to {}",
        cert.validity().not_before,
        cert.validity().not_after);

    // Check if it's a CA certificate
    if let Some(bc) = cert.basic_constraints().ok().flatten() {
        if bc.value.ca {
            println!("      Type:    CA certificate");
        }
    }

    // Self-signed?
    if cert.subject() == cert.issuer() {
        println!("      Note:    Self-signed (root CA or self-signed cert)");
    }
}
}

Test it:

cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# Certificate chain: 3 certificates
#   [0] CN=*.google.com
#       Issuer:  CN=GTS CA 1C3
#       Valid:   2024-10-21 to 2025-01-13
#   [1] CN=GTS CA 1C3
#       Issuer:  CN=GTS Root R1
#       Valid:   2020-08-13 to 2027-09-30
#       Type:    CA certificate

# Compare with openssl:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep -E "s:|i:"

Step 4: Extract Subject Alternative Names

SANs tell you which domains the certificate covers:

#![allow(unused)]
fn main() {
fn print_sans(cert: &X509Certificate) {
    if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
        let names: Vec<String> = san_ext.value.general_names.iter()
            .filter_map(|name| match name {
                x509_parser::extensions::GeneralName::DNSName(dns) => {
                    Some(dns.to_string())
                }
                x509_parser::extensions::GeneralName::IPAddress(ip) => {
                    Some(format!("IP:{:?}", ip))
                }
                _ => None,
            })
            .collect();

        if !names.is_empty() {
            println!("      SANs:    {}", names.join(", "));
        }
    }
}
}
cargo run -p tls --bin p3-cert-inspector -- inspect google.com
# ...
# SANs: *.google.com, google.com, *.youtube.com, youtube.com, ...

Step 5: Compute days until expiry

#![allow(unused)]
fn main() {
fn days_until_expiry(cert: &X509Certificate) -> i64 {
    let not_after = cert.validity().not_after.timestamp();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() as i64;
    (not_after - now) / 86400
}
}
#![allow(unused)]
fn main() {
let days = days_until_expiry(&cert);
if days < 0 {
    println!("      ⚠ EXPIRED {} days ago!", -days);
} else if days < 30 {
    println!("      ⚠ Expires in {} days (renew soon!)", days);
} else {
    println!("      Expires in {} days", days);
}
}

Step 6: Certificate fingerprint

The SHA-256 fingerprint uniquely identifies a certificate (used for pinning):

#![allow(unused)]
fn main() {
use sha2::{Sha256, Digest};

fn cert_fingerprint(der: &[u8]) -> String {
    let hash = Sha256::digest(der);
    hash.iter()
        .map(|b| format!("{:02X}", b))
        .collect::<Vec<_>>()
        .join(":")
}
}
# Compare with openssl:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -fingerprint -sha256
# SHA256 Fingerprint=A1:B2:C3:D4:...

Step 7: Batch expiry checker

Check multiple domains at once:

#![allow(unused)]
fn main() {
async fn check_expiry(hosts: Vec<String>) {
    for host in &hosts {
        match tls_connect(host, 443).await {
            Ok(tls) => {
                let (_, conn) = tls.get_ref();
                if let Some(certs) = conn.peer_certificates() {
                    let (_, cert) = X509Certificate::from_der(&certs[0]).unwrap();
                    let days = days_until_expiry(&cert);
                    let status = if days < 0 { "EXPIRED" }
                        else if days < 30 { "⚠ RENEW SOON" }
                        else { "✓" };
                    println!("{:<30} {:>4} days  {}", host, days, status);
                }
            }
            Err(e) => {
                println!("{:<30} ERROR: {}", host, e);
            }
        }
    }
}
}
cargo run -p tls --bin p3-cert-inspector -- check-expiry \
  google.com github.com cloudflare.com expired.badssl.com

# google.com                       62 days  ✓
# github.com                      198 days  ✓
# cloudflare.com                  150 days  ✓
# expired.badssl.com              ERROR: certificate has expired

Test targets

badssl.com provides certificates with every kind of problem — perfect for testing:

# Working:
cargo run -p tls --bin p3-cert-inspector -- inspect sha256.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect tls-v1-2.badssl.com

# Broken (your tool should show useful errors):
cargo run -p tls --bin p3-cert-inspector -- inspect expired.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect wrong.host.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect self-signed.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect untrusted-root.badssl.com
cargo run -p tls --bin p3-cert-inspector -- inspect revoked.badssl.com

Tip: for sites with invalid certs, you’ll need to configure rustls to accept them (for inspection only). Create a custom ServerCertVerifier that accepts everything:

#![allow(unused)]
fn main() {
// DANGEROUS — for inspection only, never in production
struct NoVerify;
impl rustls::client::danger::ServerCertVerifier for NoVerify {
    fn verify_server_cert(&self, ...) -> Result<...> {
        Ok(rustls::client::danger::ServerCertVerified::assertion())
    }
    // ... implement other required methods
}
}

This lets you connect to expired/self-signed sites to inspect their certs.

Exercises

Exercise 1: Basic inspector

Connect, extract chain, print subject/issuer/validity/SANs for each cert. Compare your output with openssl s_client.

Exercise 2: Batch expiry checker

cargo run -p tls --bin p3-cert-inspector -- check-expiry \
  google.com github.com example.com expired.badssl.com

Color-code output: green for >30 days, yellow for <30 days, red for expired.

Exercise 3: Certificate pinning

Download a site’s cert, compute SHA-256 fingerprint, save to a JSON file. On subsequent runs, compare the current fingerprint with the saved one. Alert if it changed (possible MITM or cert rotation).

# First run — saves the pin:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# Fingerprint: SHA-256:A1:B2:C3...
# Saved to pins.json

# Later run — checks the pin:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# Fingerprint: SHA-256:A1:B2:C3... ✓ matches saved pin

# After cert rotation:
cargo run -p tls --bin p3-cert-inspector -- pin google.com
# ⚠ FINGERPRINT CHANGED!
# Old: SHA-256:A1:B2:C3...
# New: SHA-256:D4:E5:F6...

Exercise 4: JSON output

Add --json flag for machine-readable output:

cargo run -p tls --bin p3-cert-inspector -- inspect --json google.com
{
  "host": "google.com",
  "port": 443,
  "protocol": "TLS 1.3",
  "cipher": "TLS_AES_256_GCM_SHA384",
  "chain": [
    {
      "subject": "CN=*.google.com",
      "issuer": "CN=GTS CA 1C3",
      "not_before": "2024-10-21",
      "not_after": "2025-01-13",
      "days_until_expiry": 62,
      "sans": ["*.google.com", "google.com", "*.youtube.com"],
      "fingerprint": "SHA-256:A1:B2:C3:D4..."
    }
  ]
}

This is useful for monitoring scripts that parse the output programmatically.

Lesson 9: Encrypted Echo Server (no authentication)

Alice’s Bookstore — Chapter 9

Alice now understands all the building blocks: hashing, encryption, signatures, key exchange, key derivation, and certificates. Bob challenges her:

“Let’s put it all together. Build an actual encrypted communication channel between your server and a client. Key exchange, then encrypted messages back and forth.”

“So we’re building… TLS?”

“A baby version of it. No certificates yet — just DH key exchange + encryption. We’ll add authentication in the next lesson.”

“What happens without authentication?”

“It works great against Eve — she can’t read anything. But Mallory can still pretend to be your server. One problem at a time.”

Real-life analogy: the walkie-talkie with a scrambler

Imagine two people with walkie-talkies that have a built-in scrambler:

┌──────────────────────────────────────────────────────────┐
│  Before talking:                                         │
│    1. Both press a "pair" button simultaneously           │
│       (key exchange — they agree on a scramble pattern)  │
│    2. The scrambler activates                            │
│                                                          │
│  During conversation:                                    │
│    Alice speaks → scrambler garbles it → radio sends     │
│    Bob's radio receives → unscrambler restores it        │
│                                                          │
│  Anyone listening on the same frequency:                 │
│    Hears only garbled noise. Can't understand a word.    │
│                                                          │
│  The catch:                                              │
│    Anyone could have pressed "pair" with Alice.          │
│    She doesn't know if it's really Bob. (No auth!)       │
│    That's what Lesson 10 fixes.                          │
└──────────────────────────────────────────────────────────┘

What we’re building

A TCP echo server and client that communicate over an encrypted channel. This combines Lessons 2, 4, and 5 into a working protocol:

  1. Key exchange (Lesson 4): X25519 Diffie-Hellman
  2. Key derivation (Lesson 5): HKDF to produce two independent keys
  3. Encrypted messaging (Lesson 2): ChaCha20-Poly1305 with length-prefixed framing

This is essentially a simplified TLS session — without authentication (that’s Lesson 10).

The protocol

Client                                    Server
  │                                         │
  │──── client_public (32 bytes) ─────────►│   Handshake: raw bytes,
  │◄──── server_public (32 bytes) ─────────│   no framing needed (fixed size)
  │                                         │
  │  shared = DH(my_secret, their_public)   │   Both sides compute independently
  │  c2s_key = HKDF(shared, "c2s")          │   Both derive the same two keys
  │  s2c_key = HKDF(shared, "s2c")          │
  │                                         │
  │── [2B len][12B nonce][ciphertext] ────►│   Encrypted with c2s_key
  │◄── [2B len][12B nonce][ciphertext] ────│   Encrypted with s2c_key
  │                                         │

Why two different keys?

If both directions used the same key, an attacker could reflect messages: capture an encrypted message from client→server and send it back to the client. The client would successfully decrypt it (same key) and think the server sent it.

With separate keys: a message encrypted with c2s_key can only be decrypted by someone who has c2s_key. If an attacker reflects it back to the client, the client tries to decrypt with s2c_key — it fails.

Message format

Each encrypted message on the wire looks like:

┌─────────┬──────────┬────────────────────────────┐
│ 2 bytes │ 12 bytes │ N + 16 bytes               │
│ length  │  nonce   │ ciphertext + auth tag       │
└─────────┴──────────┴────────────────────────────┘
         └── length covers nonce + ciphertext ──┘

The 2-byte length prefix tells the receiver how many bytes to read. Without it, TCP is a byte stream — the receiver has no way to know where one message ends and the next begins.

Real-world scenario: Alice and Bob’s encrypted chat

Alice and Bob want to chat privately. Eve is monitoring the network.

The handshake

  1. Alice generates an ephemeral X25519 key pair
  2. Alice sends her 32-byte public key to Bob
  3. Bob generates an ephemeral X25519 key pair
  4. Bob sends his 32-byte public key to Alice
  5. Both compute: shared_secret = DH(my_secret, their_public)
  6. Both derive: c2s_key = HKDF(shared, "c2s") and s2c_key = HKDF(shared, "s2c")

Eve sees both public keys on the wire. She cannot compute the shared secret (Lesson 4).

Encrypted communication

  1. Alice types “meet at 3pm”
  2. Alice generates a random 12-byte nonce
  3. Alice encrypts: ciphertext = ChaCha20Poly1305(c2s_key, nonce, "meet at 3pm")
  4. Alice sends: [length][nonce][ciphertext]
  5. Bob reads the length, reads that many bytes, splits nonce and ciphertext
  6. Bob decrypts: plaintext = ChaCha20Poly1305(c2s_key, nonce, ciphertext) → “meet at 3pm”
  7. Bob echoes back, encrypted with s2c_key

Eve sees: [0x00 0x1d][random 12 bytes][random-looking bytes]. She can see the message length (29 bytes = 12 nonce + 13 plaintext + 4… wait, actually 12 + 11 + 16 = 39). She knows a message was sent and its approximate size, but not the content.

What Eve CAN still learn (traffic analysis)

Even with encryption, Eve can observe:

  • When messages are sent (timing)
  • How large each message is (length prefix is plaintext)
  • How many messages are exchanged
  • Who is talking to whom (IP addresses)

TLS has the same limitation. This is why some protocols add padding to obscure message sizes.

The vulnerability: no authentication

This implementation is vulnerable to man-in-the-middle attacks.

Alice ←──DH──→ Mallory ←──DH──→ Bob (Server)
       key_A_M           key_M_B

Mallory intercepts Alice’s public key, does her own DH with Alice (key_A_M) and a separate DH with Bob (key_M_B). She decrypts Alice’s messages with key_A_M, reads them, re-encrypts with key_M_B, and forwards to Bob.

Lesson 10 fixes this by having the server sign its public key, proving its identity.

Try it yourself

# Terminal 1: start the server
cargo run -p tls --bin 9-echo-server

# Terminal 2: start the client
cargo run -p tls --bin 9-echo-client
# Type a message, see it echoed back encrypted.
# Capture the traffic to see encryption in action:
# Terminal 1: capture
sudo tcpdump -i lo0 port 7878 -w /tmp/echo-encrypted.pcap &

# Terminal 2: run server
cargo run -p tls --bin 9-echo-server &

# Terminal 3: run client, send a message
echo "hello secret world" | cargo run -p tls --bin 9-echo-client

# Stop capture
kill %1

# Inspect — you'll see the DH public keys (plaintext) then encrypted data:
tcpdump -r /tmp/echo-encrypted.pcap -X 2>/dev/null | head -40
# First 32 bytes: client's DH public key (readable hex)
# Next 32 bytes: server's DH public key
# Everything after: random-looking bytes (encrypted!)

Comparison with real TLS

FeatureOur implementationTLS 1.3
Key exchangeX25519X25519 or P-256
Key derivationHKDF-SHA256HKDF-SHA256 or SHA384
EncryptionChaCha20-Poly1305ChaCha20-Poly1305 or AES-GCM
AuthenticationNoneCertificates + signatures
NonceRandom per messageCounter (sequence number)
Handshake1-RTT (2 messages)1-RTT (2 flights)
Session resumptionNo0-RTT with PSK
Record framing2-byte length2-byte length + type + version

Our protocol is structurally similar to TLS 1.3 — just stripped down to the essentials.

Exercises

Exercise 1: Encrypted echo (implemented in 9-echo-server.rs and 9-echo-client.rs)

Build the server and client as described above. Type messages in the client, see them echoed back.

Exercise 2: Graceful disconnection

The current implementation panics when the client disconnects. Make recv_encrypted return a Result and handle EOF gracefully — server prints “client disconnected” and waits for a new connection.

Exercise 3: Counter nonce

Replace random nonces with a counter. Each side maintains a u64 counter starting at 0, incremented after each message. Encode it as the last 8 bytes of the 12-byte nonce (first 4 bytes = 0). This is what TLS does — it guarantees uniqueness without relying on randomness.

Exercise 4: Bidirectional chat

Modify the client to not just send-then-receive, but handle both directions concurrently. Use threads or async: one thread reads from stdin and sends, another reads from the server and prints. This makes it a real chat application.

Exercise 5: Wireshark capture

Run the echo server/client and capture traffic with:

sudo tcpdump -i lo0 port 7878 -w capture.pcap

Open in Wireshark. You’ll see the TCP stream with:

  • First 32 bytes: client’s DH public key (plaintext)
  • Next 32 bytes: server’s DH public key (plaintext)
  • Everything after: encrypted messages (random-looking)

Compare this with a plaintext TCP echo server — the difference is visible.

Lesson 10: Authenticated Echo Server

Alice’s Bookstore — Chapter 10

Alice’s encrypted channel from Lesson 9 is working. Customers can communicate securely. Then Mallory strikes:

Mallory sets up a fake server at alices-b00kstore.com (with zeros instead of o’s). When a customer connects, Mallory does her own DH key exchange with them. The customer thinks they’re talking to Alice — the connection is encrypted, everything looks fine — but Mallory is reading every message.

“I thought encryption solved this!”

Bob: “Encryption protects the pipe, but doesn’t prove who’s on the other end. You need to SIGN your DH public key so the customer can verify it’s really you. Mallory can’t forge your signature.”

“So the server proves its identity during the handshake?”

“Exactly. That’s authentication.”

Real-life analogy: the phone call with caller ID

Without authentication (Lesson 9):
  Phone rings: "Hi, this is your bank. What's your account number?"
  You: "Sure, it's 12345"         ← could be a scammer!

With authentication (this lesson):
  Phone rings: "Hi, this is your bank"
  You: "Prove it. What's my security question?"
  Caller: "Your mother's maiden name is Smith"  ← only the real bank knows
  You: "OK, I trust you now"

In crypto terms:
  "Prove it"     = "sign your DH public key"
  "maiden name"  = the server's Ed25519 private key
  Verification   = checking the signature against a known public key

The problem with Lesson 9

In Lesson 9, we built an encrypted channel. But the client has no way to verify who it’s talking to. An attacker (Mallory) can sit between client and server, do separate DH key exchanges with each side, and read all traffic — a man-in-the-middle attack.

Client ←──DH──→ Mallory ←──DH──→ Server
        key_1             key_2

Mallory decrypts with key_1, reads, re-encrypts with key_2, forwards.
Neither side detects anything.

The solution: sign the handshake

The server has a long-term Ed25519 identity key pair (generated once, stored on disk). The client knows the server’s public key in advance. During the handshake, the server signs its ephemeral DH public key with its identity key. The client verifies the signature.

The protocol (changes from Lesson 9 in bold)

Client                                     Server
  │                                          │
  │── client_dh_public (32 bytes) ─────────►│
  │                                          │
  │◄── server_dh_public (32 bytes) ─────────│
  │◄── signature (64 bytes) ────────────────│  ** sign(identity_key, server_dh_public) **
  │                                          │
  │  ** verify(known_pubkey,                 │
  │     server_dh_public, signature) **      │
  │  ** → if fails, ABORT **                 │
  │                                          │
  │  shared = DH(my_secret, their_public)    │  (same as Lesson 9)
  │  derive keys, encrypt/decrypt            │

Only 64 bytes more on the wire. But now Mallory can’t impersonate the server.

Why Mallory can’t attack this

  1. Mallory intercepts Alice’s DH public key
  2. Mallory generates her own DH key pair, sends mallory_dh_pub to Alice
  3. Mallory needs to send a valid signature: sign(server_identity_private, mallory_dh_pub)
  4. Mallory doesn’t have server_identity_private — she can’t forge the signature
  5. Alice verifies the signature → FAILS → Alice disconnects

Mallory could sign with her own identity key, but Alice would reject it because Alice only trusts the server’s known public key.

Real-world scenarios

SSH host verification

The first time you SSH to a server, you see:

The authenticity of host 'server.com' can't be established.
ED25519 key fingerprint is SHA256:abc123...
Are you sure you want to continue connecting (yes/no)?

You’re manually deciding to trust this public key. Once you say “yes”, it’s saved in ~/.ssh/known_hosts. On subsequent connections, SSH verifies the server’s signature against the stored key. If it doesn’t match:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!

This is exactly what Lesson 10 does — the client has a known server public key and verifies the handshake signature.

TLS certificate verification

In real TLS, instead of a hardcoded public key, the server sends a certificate (Lesson 7) containing its public key, signed by a CA. The client verifies the certificate chain, extracts the public key, then verifies the handshake signature. Same principle, but with a trust hierarchy instead of a pinned key.

WireGuard peer authentication

WireGuard uses the same pattern: each peer has a long-term X25519 key pair (Curve25519, not Ed25519, but same idea). You configure each peer with the other’s public key. During the handshake, the Noise protocol uses static-ephemeral DH to authenticate — if you don’t have the right private key, the handshake fails.

Signal’s safety numbers

When you verify “safety numbers” with a Signal contact, you’re comparing long-term identity public keys. If the keys match, you know your messages are authenticated and not being intercepted. If Signal shows “safety number changed”, the contact’s identity key changed — could be a new phone, or could be a MITM.

Try it yourself

# Step 1: generate the server's identity key
cargo run -p tls --bin 10-genkey
# Private key saved to server_identity.key
# Public key: dd8c3c76bf81163f...

# Step 2: start the authenticated server
cargo run -p tls --bin 10-echo-server

# Step 3: connect with the client (hardcode the public key from step 1)
cargo run -p tls --bin 10-echo-client
# "server authenticated" → type messages, see them echoed

# Step 4: test with wrong key — change one hex digit in the client
# Run again → "server authentication failed!" → connection refused
# See SSH doing the same thing:
# First connection to a new server:
ssh -v new-server.com 2>&1 | grep -i "host key"
# "Server host key: ssh-ed25519 SHA256:..."
# "Are you sure you want to continue connecting?"

# After accepting, it's in known_hosts:
grep "new-server" ~/.ssh/known_hosts

# If the server's key changes (or MITM):
# "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!"
# Same concept as our authenticated echo server.

What this does NOT protect against

  • Compromised server: If the attacker steals the server’s private identity key, they can impersonate the server. This is why key storage matters (file permissions, HSMs in production).
  • Compromised client: If the attacker modifies the client’s known public key, they can substitute their own. This is why the trust anchor must be distributed securely.
  • Traffic analysis: Authentication doesn’t hide metadata (timing, message sizes, IP addresses).

The three binaries

10-genkey.rs (run once)

Generates an Ed25519 identity key pair. Saves the private key to server_identity.key. Prints the public key as hex for the client to use.

10-echo-server.rs

Same as Lesson 9, plus:

  • Loads the identity private key from server_identity.key
  • After sending its DH public key, signs it and sends the 64-byte signature

10-echo-client.rs

Same as Lesson 9, plus:

  • Has the server’s public key hardcoded (or as a CLI argument)
  • After receiving the DH public key and signature, verifies the signature
  • If verification fails, disconnects immediately

Comparison with real TLS

FeatureLesson 9Lesson 10TLS 1.3
Key exchangeX25519X25519X25519 or P-256
Server authNoneEd25519 signatureRSA/ECDSA/Ed25519 via certificate
Trust modelNonePinned public keyCA hierarchy
Client authNoneNoneOptional (mutual TLS)
What’s signedNothingDH public keyFull handshake transcript

In real TLS, the server signs the entire handshake transcript (all messages exchanged so far), not just the DH public key. This binds the signature to the entire handshake context, preventing more subtle attacks like transcript manipulation.

Exercises

Exercise 1: Authenticated echo (implemented in 10-echo-server.rs and 10-echo-client.rs)

Extend Lesson 9 with server authentication. Generate identity keys, sign the DH public key, verify on the client.

Exercise 2: Test with wrong key

Change one byte of the hardcoded public key in the client. Run it — it should fail with a verification error, proving that authentication works.

Exercise 3: Mutual authentication

Add client authentication too: the client also has a long-term Ed25519 key pair, and signs its DH public key. The server verifies. Now both sides know who they’re talking to. This is how mutual TLS (mTLS) works — common in service-to-service communication.

Exercise 4: Sign the transcript, not just the DH key

Instead of signing only the server’s DH public key, sign the concatenation of both DH public keys: sign(identity_key, client_dh_pub || server_dh_pub). This binds the signature to the entire key exchange, preventing an attacker from reusing a captured signature with a different client. This is closer to what real TLS does.

Lesson 11: Mutual TLS (mTLS)

Alice’s Bookstore — Chapter 11

Alice’s payment provider sends her an email:

“Starting next month, we require mutual TLS for all API calls. Your server must present a client certificate when connecting to our payment API. This proves your server is authorized to process payments.”

Alice is confused: “In Lesson 10, the SERVER proved its identity to the CLIENT. Now the payment provider wants ME — the client — to prove MY identity too?”

Bob: “That’s mutual authentication. Both sides show their badges. The payment provider proves they’re the real payment API, and YOU prove you’re an authorized merchant. Some random server that finds the API endpoint can’t just start processing payments.”

“So both sides sign their DH keys?”

“Exactly. Both sides have key pairs, both sign, both verify.”

Real-life analogy: the security checkpoint

One-way authentication (Lesson 10):
  You enter a building.
  Guard shows you their badge → you trust the building is legit.
  But the guard doesn't check YOUR badge. Anyone can walk in.

Mutual authentication (this lesson):
  You enter a building.
  Guard shows their badge → you verify they're a real guard.
  You show YOUR badge → guard verifies you're authorized.
  Both sides proved their identity before any conversation.

What changes from Lesson 10

In Lesson 10, only the server proves its identity. The client is anonymous — the server has no idea who connected. This is how most HTTPS works: the server proves it’s google.com, but Google doesn’t know who you are (until you log in).

Mutual TLS adds client authentication: the client also has a long-term identity key pair and signs its DH public key. Now both sides verify each other before exchanging any data.

The protocol

Client                                      Server
  │  (both have long-term Ed25519 keys)       │
  │                                           │
  │── client_dh_public (32 bytes) ──────────►│
  │                                           │
  │◄── server_dh_public (32 bytes) ──────────│
  │◄── server_signature (64 bytes) ──────────│  sign(server_id_key, server_dh_pub)
  │                                           │
  │  verify server signature ✓                │
  │                                           │
  │── client_signature (64 bytes) ──────────►│  sign(client_id_key, client_dh_pub)
  │── client_identity_pubkey (32 bytes) ────►│
  │                                           │
  │                    verify client signature ✓
  │                                           │
  │  (derive keys, encrypted communication)   │

Total handshake: 32 + 32 + 64 + 64 + 32 = 224 bytes (was 128 in Lesson 10).

Real-world scenarios

Kubernetes service-to-service communication

In a Kubernetes cluster, services talk to each other over the network. How does Service A know it’s really talking to Service B, and vice versa?

  1. A certificate authority (often built into the service mesh like Istio or Linkerd) issues certificates to each service
  2. When Service A calls Service B, both present their certificates
  3. Service A verifies Service B’s cert → “I’m talking to the real database service”
  4. Service B verifies Service A’s cert → “This request is from the authorized API service, not an attacker”
  5. Only then does encrypted communication begin

Without mTLS, a compromised pod could impersonate any service.

Corporate VPN / Zero Trust

Traditional VPNs: once you’re on the network, you can access everything. Zero Trust: every connection requires mutual authentication.

  1. Employee’s laptop has a client certificate (often stored in a hardware TPM)
  2. Corporate services require mTLS — they verify the client certificate
  3. Even if an attacker gets on the corporate network, they can’t access services without a valid client certificate
  4. Each service also proves its identity to the client

Banking APIs (PSD2/Open Banking)

European banking regulations (PSD2) require mutual TLS for third-party payment providers:

  1. A fintech company registers with a bank and receives a client certificate
  2. Every API call to the bank uses mTLS
  3. The bank verifies the fintech’s certificate → “This is an authorized payment provider”
  4. The fintech verifies the bank’s certificate → “This is the real bank, not a phishing server”
  5. Financial data is only exchanged after mutual verification

Client identity: pinned key vs certificate

In our implementation, the server needs to know the client’s public key in advance (hardcoded or from a file). This is the pinned key model — simple but doesn’t scale.

In real mTLS, the client sends a certificate signed by a CA that the server trusts. The server doesn’t need to know every client’s key — it just needs to trust the CA. This scales to thousands of clients.

Pinned key (our implementation):     Certificate (real mTLS):
  Server knows: [client_pubkey_1,      Server knows: [CA_pubkey]
                 client_pubkey_2,      CA signs each client's cert
                 client_pubkey_3]      Server verifies cert chain
  Doesn't scale                        Scales to thousands

Exercises

Exercise 1: Mutual authentication (implemented in 11-mtls-server.rs and 11-mtls-client.rs)

Extend Lesson 10: both sides have identity keys, both sign their DH public keys, both verify. Generate keys with 11-mtls-genkeys.rs.

Exercise 2: Authorized clients list

Modify the server to load a list of authorized client public keys from a file. Reject connections from unknown clients. Print which client connected.

Exercise 3: Wrong client key

Generate a new client key but don’t register it with the server. Connect — the server should reject the connection. This proves that only authorized clients can connect.

Exercise 4: Revocation

Add a “revoked keys” file. Even if a client has a valid key, if it’s in the revocation list, reject the connection. This simulates certificate revocation (CRL/OCSP in real TLS).

Lesson 12: Replay Attack Defense

Alice’s Bookstore — Chapter 12

Alice’s encrypted, authenticated bookstore is running smoothly. Then she notices something strange in her logs: a customer named Dave “bought” the same book 47 times in one minute. Dave calls:

“I only clicked ‘Buy’ once! But I got charged 47 times!”

Bob investigates: “Mallory recorded the encrypted ‘buy book’ message from the network. She can’t read or modify it — your encryption and authentication are solid. But she can REPLAY it. She sent the exact same encrypted bytes 46 more times, and your server processed each one as a valid purchase.”

“But the messages are encrypted! How can she reuse them?”

“She doesn’t need to understand the message. She just copies the bytes and sends them again. Your server sees valid encryption, valid auth, decrypts it, and processes it. You need sequence numbers — so your server can say ‘I already processed message #7, this is a duplicate.’”

Real-life analogy: the receipt trick

Without replay defense:
  You pay for dinner. Waiter gives you a receipt.
  A thief photographs your receipt.
  Next day, thief shows the receipt: "I already paid, here's proof"
  Restaurant accepts it — same valid receipt!

With replay defense (sequence numbers):
  Receipt #001: dinner, $50, 2024-04-07
  Receipt #002: lunch, $20, 2024-04-08
  Restaurant tracks: "I've already processed #001"
  Thief shows #001 again → "Already used. Rejected."

The attack

In Lessons 9 and 10, we use random nonces for each message. This prevents nonce reuse, but it doesn’t prevent replay attacks.

An attacker records an encrypted message (they don’t need to decrypt it). Later, they send the exact same bytes again. The server decrypts it successfully — it’s a valid ciphertext with a valid nonce. The server processes the message a second time.

Client sends: "transfer $100 to Bob"  (encrypted)
         │
         ├──────► Server processes it ✓ ($100 sent)
         │
Attacker records it, replays later:
         │
         └──────► Server processes it again ✓ ($100 sent AGAIN!)

The attacker can’t read or modify the message, but they can repeat it.

The defense: sequence numbers

Replace random nonces with a counter. Both sides maintain a send counter and a receive counter, starting at 0.

Message 0: nonce = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Message 1: nonce = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
Message 2: nonce = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]
...

The receiver expects nonces in order. If it receives a nonce it already saw → reject (replay). If it receives an out-of-order nonce → reject (reordering attack).

This is exactly what TLS 1.3 does. Each record has an implicit sequence number used as the nonce.

Why this also prevents nonce reuse

With random nonces, there’s a (tiny) chance of collision — two messages get the same random nonce. With a counter, nonces are guaranteed unique as long as the counter doesn’t wrap around. A 64-bit counter can handle 2^64 messages — far more than any session will ever send.

The new message format

Before (Lessons 7-8):
  [2B length][12B random nonce][ciphertext + tag]

After (Lesson 10):
  [2B length][ciphertext + tag]
  Nonce is derived from sequence number — not sent on the wire!

The nonce is no longer transmitted. Both sides know it because they track the counter independently. This saves 12 bytes per message AND eliminates the possibility of an attacker manipulating the nonce.

Real-world scenarios

Banking transaction replay

Alice sends an encrypted bank transfer. Mallory captures the encrypted bytes (she can’t decrypt them). A week later, Mallory sends the exact same bytes to the bank. Without replay protection, the bank decrypts it, sees a valid transfer request, and processes it again.

With sequence numbers: the bank expects message #4728 next. Mallory’s replayed message was #4500. The bank rejects it — wrong sequence number.

Game server cheating

In an online game, a player sends “use health potion” (encrypted). An attacker captures this message and replays it 100 times. Without replay protection, the player heals 100 times from one potion.

With sequence numbers: the server expects the next sequence number. Replayed messages are instantly rejected.

TLS 1.3 record numbers

Every TLS 1.3 record has an implicit 64-bit sequence number:

  • Client → Server: client maintains client_seq = 0, 1, 2, ...
  • Server → Client: server maintains server_seq = 0, 1, 2, ...

The sequence number is XORed with a per-direction IV (derived during key schedule) to produce the nonce:

nonce = IV XOR sequence_number

This guarantees unique nonces AND prevents replay.

How to build the counter nonce

#![allow(unused)]
fn main() {
fn counter_nonce(counter: u64) -> [u8; 12] {
    let mut nonce = [0u8; 12];
    nonce[4..12].copy_from_slice(&counter.to_be_bytes());
    nonce
}
}

First 4 bytes are zero, last 8 bytes are the big-endian counter. This gives you 2^64 unique nonces.

Exercises

Exercise 1: Counter-based encryption (implemented in 12-replay-server.rs and 12-replay-client.rs)

Replace random nonces with counters. Don’t send the nonce on the wire — derive it from the sequence number on both sides.

Exercise 2: Demonstrate the attack

Take the Lesson 12 server (random nonces). Record an encrypted message with tcpdump. Replay the raw bytes with a script. Show the server decrypts and processes it again. Then show the Lesson 12 server rejects the replay.

Exercise 3: Out-of-order detection

Send messages 0, 1, 2, then replay message 1. The receiver should reject it because it already processed sequence 1 and expects sequence 3.

Exercise 4: Sliding window (advanced)

In UDP protocols (like DTLS), messages can arrive out of order legitimately. Implement a sliding window: accept messages within a window of N sequence numbers, reject anything older. This is how DTLS and IPsec handle replay protection over unreliable transport.

Lesson 13: TLS Handshake Deep Dive

Alice’s Bookstore — Chapter 13

Alice has built her own encrypted, authenticated, replay-proof protocol. It works. But Bob opens Wireshark and compares her handshake with a real HTTPS connection:

“Your protocol does the basics — DH, signatures, encryption. But real TLS 1.3 does so much more in the handshake: cipher negotiation, SNI, ALPN, key schedule, transcript binding… Let me show you what actually happens when a browser connects to your bookstore.”

“Is my protocol wrong?”

“It’s not wrong — it’s just a simplified version. Understanding the full handshake will show you why each extra piece exists and what attacks it prevents.”

Prerequisites: Lessons 9-12 (you’ve built a mini-TLS). Now see how the real TLS 1.3 handshake works.

Real-life analogy: the diplomatic meeting

Two diplomats meeting for the first time:

Step 1 — Introductions (ClientHello / ServerHello):
  "I speak English, French, or German"    → cipher suites
  "I prefer to meet at my embassy"        → extensions (SNI)
  "Here's my proposed meeting protocol"   → key exchange group
  "Let's use this secret handshake"       → key share

Step 2 — Credentials (Certificate):
  "Here's my diplomatic passport"         → server certificate
  "Signed by the UN"                      → CA chain
  "Here's proof I'm really me"            → CertificateVerify (signature)

Step 3 — Agreement (Finished):
  "I confirm everything we discussed"     → handshake transcript hash
  "Let's begin the real conversation"     → application data

All of this happens in one round trip in TLS 1.3.

The TLS 1.3 handshake

Client                                           Server
  │                                                │
  │──── ClientHello ─────────────────────────────►│
  │  • Protocol version: TLS 1.3                   │
  │  • Random: 32 bytes                            │
  │  • Cipher suites: [ChaCha20, AES-256-GCM]     │
  │  • Key share: X25519 public key                │
  │  • SNI: "example.com"                          │
  │  • ALPN: ["h2", "http/1.1"]                    │
  │                                                │
  │◄──── ServerHello ─────────────────────────────│
  │  • Cipher suite: ChaCha20-Poly1305 (chosen)   │
  │  • Key share: X25519 public key (server's)     │
  │                                                │
  │  ══════ ENCRYPTED FROM HERE ══════════════════ │
  │  (keys derived from DH shared secret)          │
  │                                                │
  │◄──── EncryptedExtensions ─────────────────────│
  │  • ALPN: "h2" (chosen)                         │
  │                                                │
  │◄──── Certificate ─────────────────────────────│
  │  • Server's X.509 certificate chain            │
  │                                                │
  │◄──── CertificateVerify ───────────────────────│
  │  • Signature over handshake transcript         │
  │                                                │
  │◄──── Finished ────────────────────────────────│
  │  • HMAC over handshake transcript              │
  │                                                │
  │──── Finished ─────────────────────────────────►│
  │  • HMAC over handshake transcript              │
  │                                                │
  │◄═══════════ Application Data ═══════════════► │

Key insight: 1-RTT

TLS 1.3 completes the handshake in one round trip (1-RTT). The client sends its key share in ClientHello — no need to wait for the server to pick a group first. Compare with TLS 1.2 which needed 2-RTT.

Cipher suite negotiation

The client offers a list; the server picks one:

Client offers:                        Server picks:
  TLS_CHACHA20_POLY1305_SHA256         ✓ (selected)
  TLS_AES_256_GCM_SHA384
  TLS_AES_128_GCM_SHA256

A TLS 1.3 cipher suite specifies:

  • AEAD cipher: ChaCha20-Poly1305 or AES-GCM (your Lesson 2)
  • Hash: SHA-256 or SHA-384 (your Lesson 1)
  • Key exchange: always ephemeral DH (your Lesson 4) — not part of the cipher suite name
# See what cipher suites a server supports:
echo | openssl s_client -connect google.com:443 2>/dev/null | grep "Cipher"
# Cipher    : TLS_AES_256_GCM_SHA384

# List all TLS 1.3 cipher suites your OpenSSL supports:
openssl ciphers -v -tls1_3

Extensions

Extensions carry additional information in the handshake:

SNI (Server Name Indication)

ClientHello extension:
  server_name: "example.com"

Tells the server which hostname the client wants. Essential for virtual hosting — one IP serving multiple HTTPS sites.

# Connect with explicit SNI:
openssl s_client -connect 93.184.216.34:443 -servername example.com

# Connect WITHOUT SNI — might get wrong cert or error:
openssl s_client -connect 93.184.216.34:443

Privacy note: SNI is sent in plaintext in ClientHello. Anyone watching the network sees which site you’re connecting to. Encrypted Client Hello (ECH) aims to fix this.

ALPN (Application-Layer Protocol Negotiation)

ClientHello extension:
  alpn: ["h2", "http/1.1"]

ServerHello extension:
  alpn: "h2"

Negotiates the application protocol. Used for HTTP/2 (h2) vs HTTP/1.1 upgrade.

# Request HTTP/2 via ALPN:
openssl s_client -connect google.com:443 -alpn h2 2>/dev/null | grep "ALPN"
# ALPN protocol: h2

Key share

The client sends its DH public key directly in ClientHello (TLS 1.3’s big improvement over 1.2):

ClientHello extension:
  key_share: x25519 public key (32 bytes)

ServerHello extension:
  key_share: x25519 public key (32 bytes)

Both sides compute the shared secret immediately. No extra round trip.

The key schedule

After DH, the shared secret goes through HKDF (your Lesson 5) to derive all session keys:

DH shared secret
       │
       ▼
  HKDF-Extract(salt=0, shared_secret) → handshake_secret
       │
       ├── HKDF-Expand("c hs traffic", transcript_hash)
       │   → client_handshake_key + IV
       │
       ├── HKDF-Expand("s hs traffic", transcript_hash)
       │   → server_handshake_key + IV
       │
       └── HKDF-Extract(handshake_secret, 0) → master_secret
              │
              ├── HKDF-Expand("c ap traffic", transcript_hash)
              │   → client_application_key + IV
              │
              └── HKDF-Expand("s ap traffic", transcript_hash)
                  → server_application_key + IV

Handshake keys encrypt the Certificate, CertificateVerify, and Finished messages. Application keys encrypt the actual data (HTTP requests, etc.).

The transcript hash is a hash of all handshake messages sent so far. This binds the keys to the specific handshake — an attacker can’t mix and match messages from different handshakes.

The handshake transcript

Every handshake message is hashed together:

transcript_hash = SHA-256(
    ClientHello ||
    ServerHello ||
    EncryptedExtensions ||
    Certificate ||
    CertificateVerify ||
    server Finished
)

This hash appears in:

  • Key derivation: the transcript is an input to HKDF-Expand
  • CertificateVerify: the server signs the transcript hash
  • Finished: both sides HMAC the transcript hash

If an attacker modifies ANY handshake message, the transcript hash changes, and everything fails: keys don’t match, signatures don’t verify, Finished messages don’t validate.

Watch a real handshake

# Full handshake trace:
openssl s_client -connect example.com:443 -msg 2>&1 | head -50
# Shows raw bytes of each handshake message

# Wireshark-style breakdown:
openssl s_client -connect example.com:443 -state 2>&1 | grep "SSL_connect"
# SSL_connect:before SSL initialization
# SSL_connect:SSLv3/TLS write client hello
# SSL_connect:SSLv3/TLS read server hello
# SSL_connect:SSLv3/TLS read change cipher spec
# ...

# Detailed certificate chain:
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | \
  openssl x509 -text -noout | head -30

# See the negotiated parameters:
echo | openssl s_client -connect example.com:443 2>/dev/null | \
  grep -E "Protocol|Cipher|Server public key|Peer signing"
# Capture a handshake with tcpdump and view in Wireshark:
sudo tcpdump -i en0 -w tls-handshake.pcap host example.com and port 443 &
curl -s https://example.com > /dev/null
kill %1

# Open tls-handshake.pcap in Wireshark:
# Filter: tls.handshake
# You'll see ClientHello, ServerHello, etc. with all extensions decoded

TLS 1.3 vs 1.2

                        TLS 1.2              TLS 1.3
──────────────────────────────────────────────────────
Round trips              2-RTT               1-RTT
Key exchange             RSA or DHE          DHE only (forward secrecy)
Cipher suites            ~100+               5 (simplified)
Encryption starts        After Finished      After ServerHello
Static RSA               Allowed             Removed
Compression              Allowed             Removed (CRIME attack)
Renegotiation            Allowed             Removed
0-RTT resumption         No                  Yes (with replay risk)

Exercises

Exercise 1: Trace a handshake

Use openssl s_client -state -connect google.com:443 to see each handshake step. Identify: ClientHello, ServerHello, Certificate, Finished. What cipher suite was negotiated?

Exercise 2: SNI experiment

Connect to a shared hosting server (like Cloudflare) with different SNI values:

openssl s_client -connect 104.16.0.0:443 -servername example.com
openssl s_client -connect 104.16.0.0:443 -servername different-site.com

Compare the certificates returned. Same IP, different certs — SNI in action.

Exercise 3: Cipher suite restriction

In your tokio-rustls server (Lesson 14), configure it to only accept ChaCha20-Poly1305. Connect with a client that only offers AES-GCM. The handshake should fail. Then allow both — it should succeed.

Exercise 4: Transcript binding

In your mini-TLS (Lesson 10), modify the authenticated echo server to sign the full transcript (both DH public keys concatenated) instead of just the server’s DH key. Show that a replayed ServerHello from a different session is rejected because the transcript doesn’t match.

Exercise 5: Wireshark capture

Capture a TLS handshake with tcpdump, open in Wireshark. Identify each message type. Observe that everything after ServerHello is encrypted — you can see the handshake structure but not Certificate or Finished contents.

Project: Encrypted File Transfer

Prerequisites: Lesson 2 (Encryption), Lesson 5 (HKDF), Lesson 9-10 (Encrypted + Authenticated Echo Server). This project extends the echo server to transfer files.

What is this?

scp lets you copy files between machines over SSH. You’re building the same thing — but using the crypto you built in earlier lessons instead of SSH.

┌──────────────────────────────────────────────────────────┐
│  The problem:                                            │
│                                                          │
│  You want to send a file to another machine.             │
│                                                          │
│  Option 1: netcat (nc)                                   │
│    Fast. Simple. ZERO encryption.                        │
│    Anyone on the network can see your file.              │
│                                                          │
│  Option 2: scp / sftp                                    │
│    Encrypted via SSH. But requires SSH setup on both     │
│    machines, user accounts, authorized_keys...           │
│                                                          │
│  Option 3: your tool (this project)                      │
│    Encrypted with your mini-TLS (Lessons 9-10).          │
│    One binary on each side. No SSH, no accounts.         │
│    Authenticated — receiver proves identity.             │
│    Integrity — SHA-256 verifies the file wasn't corrupted│
└──────────────────────────────────────────────────────────┘

What you’re building

# Terminal 1 — receiver listens:
cargo run -p tls --bin p5-transfer -- receive --port 9000 --key server.key
# Listening on 0.0.0.0:9000...

# Terminal 2 — sender connects and sends a file:
cargo run -p tls --bin p5-transfer -- send \
  --host 127.0.0.1:9000 \
  --server-pubkey abc123... \
  my-file.tar.gz

# Output:
# Connected to 127.0.0.1:9000
# Server authenticated ✓
# Sending: my-file.tar.gz (4.2 MB)
# [████████████████████████] 100%  4.2 MB  2.1s  2.0 MB/s
# SHA-256: a1b2c3d4e5f6...
# Transfer complete ✓

Architecture

Sender                                  Receiver
  │                                        │
  │── DH public key (32B) ────────────────►│
  │◄── DH public key (32B) ────────────────│  Handshake
  │◄── signature (64B) ────────────────────│  (Lessons 9-10)
  │                                        │
  │  verify signature ✓                    │
  │  derive c2s_key, s2c_key               │
  │                                        │
  │── [len][encrypted metadata] ──────────►│  Step 1: what file?
  │                                        │  filename, size, SHA-256
  │                                        │
  │── [len][encrypted chunk 1] ───────────►│  Step 2: file data
  │── [len][encrypted chunk 2] ───────────►│  4KB chunks
  │── [len][encrypted chunk 3] ───────────►│  counter nonces
  │── ...                                  │
  │── [len][encrypted final chunk] ───────►│
  │                                        │
  │◄── [len][encrypted "OK" or "ERR"] ─────│  Step 3: verification
  │                                        │  receiver checks SHA-256

Try it with existing tools

# === netcat: file transfer WITHOUT encryption ===

# Terminal 1 (receiver):
nc -l 9000 > received.txt

# Terminal 2 (sender):
echo "secret document content" > secret.txt
nc 127.0.0.1 9000 < secret.txt

# The file transferred — but ANYONE on the network could read it.
# Your tool does the same thing, but encrypted.
# === scp: file transfer WITH encryption (SSH) ===
scp myfile.txt user@server:/tmp/

# This uses SSH (which uses TLS-like crypto internally).
# You're building the crypto layer yourself.
# === Verify file integrity with SHA-256 ===

# Create a test file:
dd if=/dev/urandom of=testfile.bin bs=1024 count=4096 2>/dev/null
# Created a 4MB random file

# Compute its hash:
shasum -a 256 testfile.bin
# a1b2c3d4...  testfile.bin

# This is what your tool sends as metadata — the receiver
# recomputes the hash after receiving all chunks and compares.

Implementation guide

Step 0: Project setup

touch tls/src/bin/p5-transfer.rs

Add to tls/Cargo.toml (if not already there):

serde = { version = "1", features = ["derive"] }
serde_json = "1"

CLI skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "transfer", about = "Encrypted file transfer")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Send a file
    Send {
        /// Host:port to connect to
        #[arg(long)]
        host: String,
        /// Server's public key (hex)
        #[arg(long)]
        server_pubkey: String,
        /// File to send
        file: String,
    },
    /// Receive a file
    Receive {
        /// Port to listen on
        #[arg(long, default_value = "9000")]
        port: u16,
        /// Path to server identity key
        #[arg(long)]
        key: String,
    },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Send { host, server_pubkey, file } => todo!(),
        Command::Receive { port, key } => todo!(),
    }
}

Step 1: Reuse the handshake from Lesson 10

You already built this. Copy (or import) the handshake code:

#![allow(unused)]
fn main() {
/// Sender side: connect, do DH, verify server signature
fn sender_handshake(stream: &mut TcpStream, server_pubkey: &[u8; 32])
    -> (ChaCha20Poly1305, ChaCha20Poly1305)
{
    // 1. Generate ephemeral X25519 DH key pair
    // 2. Send our DH public key (32 bytes)
    // 3. Read server's ephemeral DH public key (32 bytes) — NOT the identity key
    // 4. Read server's Ed25519 signature (64 bytes) over its DH public key
    // 5. Verify signature using server_pubkey (the identity key passed as argument)
    // 6. Compute shared secret = DH(our_secret, server_dh_public)
    // 7. Derive c2s_key and s2c_key via HKDF
    // Return (c2s_cipher, s2c_cipher)
    todo!("Reuse handshake from Lesson 10")
}

/// Receiver side: accept, do DH, sign
fn receiver_handshake(stream: &mut TcpStream, identity_key: &SigningKey)
    -> (ChaCha20Poly1305, ChaCha20Poly1305)
{
    // 1. Read client's ephemeral DH public key (32 bytes)
    // 2. Generate our own ephemeral X25519 DH key pair
    // 3. Send our DH public key (32 bytes)
    // 4. Sign our DH public key with identity_key: sign(identity_key, our_dh_public)
    // 5. Send the signature (64 bytes)
    // 6. Compute shared secret = DH(our_secret, client_dh_public)
    // 7. Derive c2s_key and s2c_key via HKDF (same labels as sender!)
    // Return (c2s_cipher, s2c_cipher)
    todo!("Reuse handshake from Lesson 10")
}
}

Test: connect sender to receiver, verify “handshake complete” prints on both sides.

Step 2: Compute file metadata

Before sending any data, tell the receiver what to expect:

#![allow(unused)]
fn main() {
use sha2::{Sha256, Digest};
use std::fs::File;
use std::io::Read;

fn compute_file_metadata(path: &str) -> (String, u64, String) {
    let mut file = File::open(path).expect("can't open file");
    let file_size = file.metadata().unwrap().len();
    let filename = std::path::Path::new(path)
        .file_name().unwrap()
        .to_str().unwrap()
        .to_string();

    // Compute SHA-256 of entire file
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 4096];
    loop {
        let n = file.read(&mut buf).unwrap();
        if n == 0 { break; }
        hasher.update(&buf[..n]);
    }
    let hash = hex::encode(hasher.finalize());

    (filename, file_size, hash)
}
}

Test:

#![allow(unused)]
fn main() {
let (name, size, hash) = compute_file_metadata("testfile.bin");
println!("File: {name}, Size: {size}, SHA-256: {hash}");

// Compare with shasum:
// shasum -a 256 testfile.bin
}

Step 3: Send metadata as first encrypted message

#![allow(unused)]
fn main() {
fn send_metadata(
    stream: &mut TcpStream,
    cipher: &ChaCha20Poly1305,
    filename: &str,
    file_size: u64,
    sha256: &str,
    nonce_counter: &mut u64,
) {
    let metadata = serde_json::json!({
        "filename": filename,
        "size": file_size,
        "sha256": sha256,
    });
    let json_bytes = metadata.to_string().into_bytes();
    send_encrypted(stream, cipher, &json_bytes, *nonce_counter);
    *nonce_counter += 1;
}
}

Here, send_encrypted is your framing function from the echo server: [2-byte length][12-byte nonce][ciphertext + tag]. But now the nonce comes from a counter (Lesson 12) instead of random.

#![allow(unused)]
fn main() {
fn counter_nonce(counter: u64) -> [u8; 12] {
    let mut nonce = [0u8; 12];
    nonce[4..12].copy_from_slice(&counter.to_be_bytes());
    nonce
}

fn send_encrypted(
    stream: &mut TcpStream,
    cipher: &ChaCha20Poly1305,
    data: &[u8],
    nonce_counter: u64,
) {
    let nonce = counter_nonce(nonce_counter);
    let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce), data).unwrap();
    let len = ciphertext.len() as u16;
    stream.write_all(&len.to_be_bytes()).unwrap();
    stream.write_all(&ciphertext).unwrap();
    // Note: nonce is NOT sent — receiver derives it from the same counter
}
}

Step 4: Send file in chunks

#![allow(unused)]
fn main() {
fn send_file(
    stream: &mut TcpStream,
    cipher: &ChaCha20Poly1305,
    path: &str,
    file_size: u64,
    nonce_counter: &mut u64,
) {
    let mut file = File::open(path).unwrap();
    let mut buf = [0u8; 4096];
    let mut sent: u64 = 0;

    loop {
        let n = file.read(&mut buf).unwrap();
        if n == 0 { break; }

        send_encrypted(stream, cipher, &buf[..n], *nonce_counter);
        *nonce_counter += 1;
        sent += n as u64;

        // Progress
        let pct = (sent as f64 / file_size as f64 * 100.0) as u32;
        eprint!("\r  Sent: {} / {} bytes ({}%)", sent, file_size, pct);
    }
    eprintln!();
}
}

Step 5: Receive and verify

The receiver mirrors the sender:

#![allow(unused)]
fn main() {
fn receive_file(
    stream: &mut TcpStream,
    cipher: &ChaCha20Poly1305,
    nonce_counter: &mut u64,
) {
    // 1. Receive metadata
    let metadata_bytes = recv_encrypted(stream, cipher, *nonce_counter);
    *nonce_counter += 1;
    let metadata: serde_json::Value = serde_json::from_slice(&metadata_bytes).unwrap();

    let filename = metadata["filename"].as_str().unwrap();
    let expected_size = metadata["size"].as_u64().unwrap();
    let expected_hash = metadata["sha256"].as_str().unwrap();

    println!("Receiving: {filename} ({expected_size} bytes)");

    // 2. Receive chunks, write to file, compute hash
    let mut output = File::create(filename).unwrap();
    let mut hasher = Sha256::new();
    let mut received: u64 = 0;

    while received < expected_size {
        let chunk = recv_encrypted(stream, cipher, *nonce_counter);
        *nonce_counter += 1;
        hasher.update(&chunk);
        output.write_all(&chunk).unwrap();
        received += chunk.len() as u64;

        let pct = (received as f64 / expected_size as f64 * 100.0) as u32;
        eprint!("\r  Received: {} / {} bytes ({}%)", received, expected_size, pct);
    }
    eprintln!();

    // 3. Verify hash
    let actual_hash = hex::encode(hasher.finalize());
    if actual_hash == expected_hash {
        println!("SHA-256 verified ✓");
        println!("File saved: {filename}");
    } else {
        eprintln!("SHA-256 MISMATCH!");
        eprintln!("  Expected: {expected_hash}");
        eprintln!("  Got:      {actual_hash}");
        std::fs::remove_file(filename).ok();
        eprintln!("File deleted — transfer corrupted.");
    }
}
}

The recv_encrypted function reads the 2-byte length, reads that many bytes of ciphertext, and decrypts with the counter nonce:

#![allow(unused)]
fn main() {
fn recv_encrypted(
    stream: &mut TcpStream,
    cipher: &ChaCha20Poly1305,
    nonce_counter: u64,
) -> Vec<u8> {
    let mut len_buf = [0u8; 2];
    stream.read_exact(&mut len_buf).unwrap();
    let len = u16::from_be_bytes(len_buf) as usize;

    let mut ciphertext = vec![0u8; len];
    stream.read_exact(&mut ciphertext).unwrap();

    let nonce = counter_nonce(nonce_counter);
    cipher.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
        .expect("Decryption failed — corrupted data or wrong counter")
}
}

Key design choice: the nonce is NOT sent on the wire. Both sides maintain the same counter independently. This saves 12 bytes per chunk AND prevents an attacker from manipulating nonces.

Step 6: Wire it all together

#![allow(unused)]
fn main() {
fn cmd_send(host: &str, server_pubkey_hex: &str, file_path: &str) {
    let mut stream = TcpStream::connect(host).unwrap();
    let server_pubkey: [u8; 32] = hex::decode(server_pubkey_hex).unwrap().try_into().unwrap();
    let (c2s_cipher, _s2c_cipher) = sender_handshake(&mut stream, &server_pubkey);
    println!("Connected, server authenticated ✓");

    let (filename, file_size, sha256) = compute_file_metadata(file_path);
    println!("Sending: {filename} ({file_size} bytes, SHA-256: {sha256})");

    let mut nonce = 0u64;
    send_metadata(&mut stream, &c2s_cipher, &filename, file_size, &sha256, &mut nonce);
    send_file(&mut stream, &c2s_cipher, file_path, file_size, &mut nonce);

    println!("Transfer complete ✓");
}

fn cmd_receive(port: u16, key_path: &str) {
    let listener = TcpListener::bind(format!("0.0.0.0:{port}")).unwrap();
    println!("Listening on 0.0.0.0:{port}...");

    let identity_key = load_identity_key(key_path);
    let (mut stream, addr) = listener.accept().unwrap();
    println!("Connection from {addr}");

    let (c2s_cipher, _s2c_cipher) = receiver_handshake(&mut stream, &identity_key);
    println!("Handshake complete ✓");

    let mut nonce = 0u64;
    receive_file(&mut stream, &c2s_cipher, &mut nonce);
}
}

Step 7: Test it end-to-end

# Generate server identity key:
cargo run -p tls --bin 10-genkey
# Public key: dd8c3c76...

# Create a test file:
dd if=/dev/urandom of=testfile.bin bs=1024 count=1024 2>/dev/null
shasum -a 256 testfile.bin
# a1b2c3d4...

# Terminal 1 — receiver:
cargo run -p tls --bin p5-transfer -- receive --port 9000 --key server_identity.key

# Terminal 2 — sender:
cargo run -p tls --bin p5-transfer -- send \
  --host 127.0.0.1:9000 \
  --server-pubkey dd8c3c76... \
  testfile.bin

# Expected output (sender):
#   Connected, server authenticated ✓
#   Sending: testfile.bin (1048576 bytes)
#   Sent: 1048576 / 1048576 bytes (100%)
#   Transfer complete ✓

# Expected output (receiver):
#   Listening on 0.0.0.0:9000...
#   Connection from 127.0.0.1:54321
#   Handshake complete ✓
#   Receiving: testfile.bin (1048576 bytes)
#   Received: 1048576 / 1048576 bytes (100%)
#   SHA-256 verified ✓
#   File saved: testfile.bin

# Verify the received file matches:
shasum -a 256 testfile.bin
# Should match the original hash

Security properties

What this tool provides:
  ✓ Confidentiality    — file data is encrypted (ChaCha20-Poly1305)
  ✓ Integrity          — SHA-256 hash verifies file wasn't corrupted
  ✓ Authentication     — server proves identity via Ed25519 signature
  ✓ Replay defense     — counter nonces prevent chunk replay/reorder
  ✓ Tamper detection   — AEAD tag on each chunk detects modification

What it does NOT provide:
  ✗ Client authentication — receiver doesn't verify who's sending
  ✗ Resume after disconnect — must restart from scratch
  ✗ Multiple files in one session
  ✗ Compression

Exercises

Exercise 1: Basic transfer

Implement all steps above. Transfer a 1MB file, verify SHA-256 matches on both sides.

Exercise 2: Progress bar

Show a visual progress bar with speed and ETA:

  testfile.bin  [██████████░░░░░░░░░░] 52%  524KB/1MB  1.2MB/s  ETA 0.4s

Hint: \r carriage return overwrites the current line.

Exercise 3: Multiple files

Accept multiple file paths. Send metadata for each, then data. The receiver creates all files:

cargo run -p tls --bin p5-transfer -- send \
  --host 127.0.0.1:9000 \
  --server-pubkey abc... \
  file1.txt file2.pdf file3.tar.gz

Exercise 4: Resume interrupted transfer

If the connection drops mid-transfer:

  1. Receiver saves how many bytes/chunks were received
  2. On reconnect, receiver tells sender “I have chunks 0-47”
  3. Sender skips those chunks, continues from chunk 48

This requires a “resume” protocol message after the handshake.

Lesson 14: Real TLS with tokio-rustls

Alice’s Bookstore — Chapter 14

Alice looks at her hand-built protocol: DH key exchange, HKDF, ChaCha20-Poly1305, Ed25519 signatures, counter nonces. It works, but…

“Bob, I built this to learn. But should I actually use it in production? I’m worried I missed something — a subtle timing attack, a nonce edge case, some protocol flaw I don’t even know about.”

“Absolutely not. Never use your own crypto in production. You built it to understand what happens inside TLS. Now you replace it with a battle-tested library that does the same thing — but has been audited by hundreds of experts.”

“So all that work was for nothing?”

“No — it was for understanding. When you use tokio-rustls now, you’ll know exactly what it’s doing under the hood. You won’t be cargo-culting. You’ll debug TLS issues faster than anyone who never built their own.”

Real-life analogy: from hand-built to factory-made

You've been building a car from scratch:
  Engine (encryption) → Transmission (key exchange) → Brakes (authentication)

Now you walk into a car factory (rustls):
  "Oh, they do the same thing I did, but with:
   - Better engineering
   - Safety testing
   - Mass production efficiency
   - Features I didn't think of (session resumption, ALPN, SNI)"

You understand every part because you built one yourself.

The payoff

You’ve built TLS from scratch: key exchange (Lesson 4), key derivation (Lesson 5), encryption (Lesson 2), authentication (Lesson 12), replay defense (Lesson 12). Now use a production TLS library and see how everything maps.

rustls vs OpenSSL

Two main TLS libraries in the Rust ecosystem:

rustlsOpenSSL (via openssl crate)
LanguagePure RustC (with Rust bindings)
SafetyMemory-safe by defaultLong history of CVEs (Heartbleed, etc.)
PerformanceCompetitive, sometimes fasterHardware-accelerated AES
CiphersModern only (TLS 1.2+)Everything including legacy
DependenciesNo C toolchain neededRequires OpenSSL headers/lib

rustls is the natural choice for Rust projects. tokio-rustls wraps it for async I/O.

How rustls maps to your lessons

When you call TlsConnector::connect(), here’s what happens inside:

1. Client sends ClientHello
   → "I support X25519 key exchange, ChaCha20-Poly1305 encryption"       (Lessons 2, 4)

2. Server responds with ServerHello + Certificate + CertificateVerify
   → Server's DH public key                                               (Lesson 4)
   → Server's X.509 certificate                                           (Lesson 6)
   → Signature over handshake transcript                                   (Lesson 3)

3. Both sides derive keys
   → HKDF from DH shared secret                                           (Lesson 5)

4. Encrypted application data flows
   → ChaCha20-Poly1305 with counter nonces                                (Lessons 2, 10)
   → Server authenticated via certificate                                  (Lesson 12)

Everything you built by hand — rustls does automatically in ~2ms.

The code change

Your Lesson 14 server/client required ~50 lines of handshake code. With tokio-rustls:

Server:

#![allow(unused)]
fn main() {
let acceptor = TlsAcceptor::from(Arc::new(server_config));
let (tcp_stream, _) = listener.accept().await?;
let tls_stream = acceptor.accept(tcp_stream).await?;
// tls_stream implements AsyncRead + AsyncWrite
// Just read and write plaintext — TLS handles everything
}

Client:

#![allow(unused)]
fn main() {
let connector = TlsConnector::from(Arc::new(client_config));
let tcp_stream = TcpStream::connect("127.0.0.1:7878").await?;
let tls_stream = connector.connect(server_name, tcp_stream).await?;
// Read and write plaintext
}

That’s it. The entire handshake — DH, signatures, certificates, key derivation — happens inside accept() / connect().

Generating proper certificates

For Lesson 6, you generated a self-signed cert with openssl CLI. For rustls, you need:

  1. A CA certificate (self-signed root)
  2. A server certificate signed by that CA
  3. The server’s private key

You can use the rcgen crate to generate these in Rust, or use openssl:

# Generate CA key and cert
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout ca.key -out ca.crt -days 365 -subj "/CN=My CA"

# Generate server key and CSR
openssl req -newkey rsa:2048 -nodes \
  -keyout server.key -out server.csr -subj "/CN=localhost"

# Sign server cert with CA
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out server.crt -days 365

The client trusts ca.crt. The server presents server.crt + server.key.

Real-world scenarios

Adding TLS to any TCP service

You have a plaintext TCP service (database, cache, message broker). Adding TLS:

  1. Generate certificates
  2. Server: wrap TcpListener with TlsAcceptor
  3. Client: wrap TcpStream with TlsConnector
  4. No other code changes — the TLS stream implements the same Read/Write traits

This is how PostgreSQL, Redis, and gRPC add TLS. The application protocol doesn’t change.

The difference you can see

Run your Lesson 14 server and capture traffic:

sudo tcpdump -i lo0 port 7878 -w lesson8.pcap

Run your Lesson 11 server and capture traffic:

sudo tcpdump -i lo0 port 7878 -w lesson11.pcap

Open both in Wireshark. Lesson 12 shows your custom handshake (raw DH keys). Lesson 11 shows a standard TLS 1.3 handshake that Wireshark can parse and label: ClientHello, ServerHello, Certificate, Finished, Application Data.

Exercises

Exercise 1: TLS echo server (implemented in 14-real-tls-server.rs and 14-real-tls-client.rs)

Build an echo server using tokio-rustls. Generate certificates, configure server and client, verify encrypted communication works.

Exercise 2: Inspect with openssl s_client

Start your TLS server, then connect with:

openssl s_client -connect 127.0.0.1:7878 -CAfile ca.crt

You’ll see the full TLS handshake: protocol version, cipher suite, certificate chain. Type a message — it gets echoed through real TLS.

Exercise 3: Cipher suite selection

Print which cipher suite was negotiated:

#![allow(unused)]
fn main() {
let (_, server_conn) = tls_stream.get_ref();
println!("Cipher: {:?}", server_conn.negotiated_cipher_suite());
}

Try configuring the server to only allow ChaCha20-Poly1305 or only AES-GCM and see what gets selected.

Exercise 4: Compare with your hand-built TLS

Run both Lesson 12 and Lesson 11 servers. Use time to measure handshake latency. Compare code complexity. Think about what rustls handles that your implementation doesn’t: cipher negotiation, session resumption, certificate chain validation, ALPN, SNI, etc.

Lesson 15: HTTPS Client

Alice’s Bookstore — Chapter 15 (Finale)

Alice’s bookstore is now running on real TLS with tokio-rustls. Bob suggests one final exercise:

“You’ve been building the server side. Now build the client side — connect to a real website over HTTPS, do the TLS handshake, send an HTTP request, get back HTML. When you see HTML from example.com appear in your terminal, you’ll know that every concept from Lessons 1 through 14 just happened in about 50 milliseconds.”

Alice builds it. She runs it. HTML appears. She stares at it for a moment.

“Hashing, encryption, signatures, key exchange, key derivation, certificates, authentication, replay defense… all of that just happened?”

“All of it. In one handshake. And now you understand every layer.”

Alice’s bookstore is secure. Her customers’ credit cards are safe. Eve can’t read them. Mallory can’t impersonate her. Replayed messages are rejected. The full circle is complete.

Real-life analogy: making a phone call to a business

You call a company:
  1. You dial the number              → TCP connect to port 443
  2. Receptionist answers:            → TLS handshake begins
     "Company X, how can I help?"
  3. You verify it's really them      → certificate verification
     (caller ID, security question)
  4. You have a private conversation  → encrypted HTTP request/response
  5. You hang up                      → connection close

This lesson: you’re building the phone. The network is the phone line. TLS is the encryption. HTTP is the conversation.

The full circle

In Lesson 1, you hashed a file. Now you’ll connect to a real website over TLS — using every concept you’ve learned. When you run this program and see HTML from example.com, know that under the hood:

  1. Your client and the server exchanged X25519 keys (Lesson 4)
  2. The server’s certificate was verified against a CA (Lesson 6)
  3. The server signed the handshake (Lesson 3/8)
  4. Session keys were derived with HKDF (Lesson 5)
  5. All data is encrypted with AES-GCM or ChaCha20-Poly1305 (Lesson 2)
  6. Each record has a sequence number nonce (Lesson 12)
  7. The hash of the handshake transcript ties it all together (Lesson 1)

What HTTPS actually is

HTTPS = HTTP over TLS over TCP. That’s it.

Application:  HTTP (GET /index.html, 200 OK, headers, body)
Security:     TLS  (handshake, encrypt, authenticate)
Transport:    TCP  (reliable byte stream)
Network:      IP   (routing)

The HTTP protocol doesn’t change at all. You send the same GET / HTTP/1.1\r\n request. The only difference is that the TCP stream is wrapped in TLS, so everything is encrypted.

Server Name Indication (SNI)

One IP address can host many HTTPS websites (virtual hosting). But TLS handshake happens before HTTP, so the server doesn’t know which site you want yet. SNI solves this: the client sends the hostname in the ClientHello (plaintext).

#![allow(unused)]
fn main() {
let server_name = "example.com".try_into().unwrap();
connector.connect(server_name, tcp_stream).await?;
//                ^^^^^^^^^^^^ sent in ClientHello
}

The server uses SNI to pick the right certificate. This is why SNI is visible to network observers — it’s the one piece of metadata that leaks in TLS (Encrypted Client Hello / ECH aims to fix this).

Root certificate stores

Your browser and OS ship with ~150 trusted root CA certificates. These are the trust anchors for the entire web.

In Rust, you have two options:

  • webpki-roots: Mozilla’s root store compiled into your binary. No system dependency.
  • rustls-native-certs: Loads from the OS certificate store. Respects system-level CA additions/removals.

For a simple client, webpki-roots is easiest.

Real-world scenarios

What your browser does thousands of times a day

Every time you visit a website:

  1. DNS lookup → IP address
  2. TCP connect to port 443
  3. TLS handshake (what you built in Lessons 4-8, plus certificate chain validation from Lesson 6)
  4. Send HTTP request through the encrypted tunnel
  5. Receive HTTP response
  6. Render HTML

Your browser does this in ~100ms. The TLS handshake is typically 1 RTT (TLS 1.3) or 2 RTT (TLS 1.2).

curl under the hood

When you run curl https://example.com, it does exactly what this lesson implements:

  1. Connects TCP to example.com:443
  2. Does a TLS handshake (using OpenSSL or rustls depending on build)
  3. Sends GET / HTTP/1.1\r\nHost: example.com\r\n\r\n
  4. Prints the response body

You’re building curl’s core networking in ~30 lines of Rust.

API clients

Every REST API call over HTTPS follows this pattern. When your code calls reqwest::get("https://api.github.com/users/octocat"), it’s doing a TLS handshake, sending HTTP, and parsing the response — exactly what you’re implementing here.

What you’ll see in the output

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
...

<!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

Real HTML, fetched over real TLS, verified against real CA certificates.

Exercises

Exercise 1: HTTPS GET (implemented in 15-https-client.rs)

Connect to example.com:443 over TLS, send an HTTP GET request, print the response.

Exercise 2: Print TLS details

After the handshake, print:

  • TLS protocol version (should be TLS 1.3)
  • Negotiated cipher suite
  • Server certificate subject name
  • Certificate chain (list each cert’s subject and issuer)

Exercise 3: Try different sites

Connect to google.com, github.com, cloudflare.com. Compare the cipher suites and certificate chains. Some use ECDSA certificates, some use RSA. Some have 2-cert chains, some have 3.

Exercise 4: Certificate pinning

Hardcode the expected SHA-256 fingerprint of example.com’s certificate. After the handshake, compute the fingerprint of the received certificate and compare. If they don’t match, abort. This is certificate pinning — extra security beyond CA validation.

Exercise 5: Connect without trusting the CA

Create a ClientConfig with an empty root store. Try connecting to example.com. The handshake should fail with a certificate verification error. This proves that the CA trust chain is enforced.

Project: HTTPS Server

Prerequisites: Lesson 8 (Certificate Generation), Lesson 14 (tokio-rustls). Serve a web page over TLS.

What is this?

Every time you visit a website with the padlock icon, you’re using HTTPS — HTTP over TLS. You’re building the server side: accept browser connections, do the TLS handshake, serve HTML.

┌──────────────────────────────────────────────────────────┐
│  What happens when you type https://localhost:8443       │
│                                                          │
│  1. Browser connects via TCP to port 8443                │
│  2. TLS handshake (your cert, key exchange, encryption)  │
│  3. Browser sends: GET / HTTP/1.1\r\n                    │
│  4. Your server responds: 200 OK + HTML                  │
│  5. Browser renders the page + shows padlock 🔒          │
│                                                          │
│  Without TLS (plain HTTP):                               │
│    Same thing, but no encryption.                        │
│    Anyone on the network sees the HTML and all data.     │
│    Browser shows "Not Secure" ⚠️                         │
└──────────────────────────────────────────────────────────┘

What you’re building

cargo run -p tls --bin p6-https-server
# Listening on https://127.0.0.1:8443

# Open in browser: https://127.0.0.1:8443
# → TLS handshake → padlock icon → your HTML page

Try it with existing tools first

# === Python: HTTPS server in 3 lines ===
# First, generate a cert:
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout server.key -out server.crt -days 365 -subj "/CN=localhost"

# Start an HTTPS server:
python3 -c "
import http.server, ssl
server = http.server.HTTPServer(('127.0.0.1', 8443), http.server.SimpleHTTPRequestHandler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain('server.crt', 'server.key')
server.socket = ctx.wrap_socket(server.socket, server_side=True)
print('Listening on https://127.0.0.1:8443')
server.serve_forever()
"

# Test with curl (skip cert verification for self-signed):
curl -k https://127.0.0.1:8443/
# Shows directory listing

# Test with openssl:
echo "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" | \
  openssl s_client -connect 127.0.0.1:8443 -quiet 2>/dev/null

Architecture

Browser                          Your HTTPS Server
  │                                    │
  ├── TCP connect :8443 ─────────────►│
  │                                    │
  │◄── TLS handshake ────────────────►│  rustls handles:
  │    ClientHello / ServerHello       │  - certificate
  │    Certificate / Finished          │  - key exchange
  │                                    │  - encryption setup
  │                                    │
  │── GET / HTTP/1.1\r\n ───────────►│  encrypted inside TLS
  │   Host: localhost\r\n              │
  │   \r\n                             │
  │                                    │
  │◄── HTTP/1.1 200 OK\r\n ─────────│  your response
  │    Content-Type: text/html\r\n     │  (also encrypted)
  │    Content-Length: 45\r\n           │
  │    \r\n                            │
  │    <h1>Hello from Rust!</h1>       │

Implementation guide

Step 0: Project setup

touch tls/src/bin/p6-https-server.rs

Add to tls/Cargo.toml:

tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] }
tokio-rustls = "0.26"
rustls = "0.23"
rcgen = "0.13"

Step 1: Generate a self-signed certificate (in code)

No openssl CLI needed — use rcgen from Lesson 8:

#![allow(unused)]
fn main() {
use rcgen::generate_simple_self_signed;

fn generate_cert() -> (Vec<u8>, Vec<u8>) {
    let cert = generate_simple_self_signed(vec![
        "localhost".into(),
        "127.0.0.1".into(),
    ]).unwrap();

    let cert_der = cert.cert.der().to_vec();
    let key_der = cert.key_pair.serialize_der();

    println!("Generated self-signed cert for localhost");
    (cert_der, key_der)
}
}

Test: print the cert PEM and inspect with openssl:

#![allow(unused)]
fn main() {
println!("{}", cert.cert.pem());
// Save to cert.pem, then:
// openssl x509 -in cert.pem -text -noout
}

Step 2: Configure rustls server

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rustls::ServerConfig;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};

fn make_tls_config(cert_der: Vec<u8>, key_der: Vec<u8>) -> Arc<ServerConfig> {
    let certs = vec![CertificateDer::from(cert_der)];
    let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der));

    let config = ServerConfig::builder()
        .with_no_client_auth()
        .with_single_cert(certs, key)
        .expect("bad cert/key");

    Arc::new(config)
}
}

Step 3: Accept TLS connections

use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;

#[tokio::main]
async fn main() {
    let (cert_der, key_der) = generate_cert();
    let tls_config = make_tls_config(cert_der, key_der);
    let acceptor = TlsAcceptor::from(tls_config);

    let listener = TcpListener::bind("127.0.0.1:8443").await.unwrap();
    println!("Listening on https://127.0.0.1:8443");

    loop {
        let (tcp_stream, addr) = listener.accept().await.unwrap();
        let acceptor = acceptor.clone();

        tokio::spawn(async move {
            match acceptor.accept(tcp_stream).await {
                Ok(mut tls_stream) => {
                    println!("[{addr}] TLS handshake complete");
                    handle_request(&mut tls_stream).await;
                }
                Err(e) => eprintln!("[{addr}] TLS error: {e}"),
            }
        });
    }
}

Test: the server starts. Connect with curl:

curl -k https://127.0.0.1:8443/
# Hangs — because handle_request is not implemented yet

Step 4: Parse HTTP request and respond

HTTP/1.1 is just text over TCP (which is now TLS):

#![allow(unused)]
fn main() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_rustls::server::TlsStream;
use tokio::net::TcpStream;

async fn handle_request(stream: &mut TlsStream<TcpStream>) {
    // Read the HTTP request
    let mut buf = [0u8; 4096];
    let n = stream.read(&mut buf).await.unwrap_or(0);
    if n == 0 { return; }

    let request = String::from_utf8_lossy(&buf[..n]);
    let first_line = request.lines().next().unwrap_or("");
    println!("  Request: {first_line}");

    // Route
    let (status, body) = if first_line.starts_with("GET / ") {
        ("200 OK", "<html><body><h1>Hello from Rust HTTPS!</h1><p>Your connection is encrypted.</p></body></html>")
    } else if first_line.starts_with("GET /about") {
        ("200 OK", "<html><body><h1>About</h1><p>Built with tokio-rustls.</p></body></html>")
    } else {
        ("404 Not Found", "<html><body><h1>404</h1><p>Page not found.</p></body></html>")
    };

    // Send HTTP response
    let response = format!(
        "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    stream.write_all(response.as_bytes()).await.ok();
}
}

Step 5: Test it

# Start the server:
cargo run -p tls --bin p6-https-server

# Test with curl:
curl -k https://127.0.0.1:8443/
# <html><body><h1>Hello from Rust HTTPS!</h1>...

curl -k https://127.0.0.1:8443/about
# <html><body><h1>About</h1>...

curl -k https://127.0.0.1:8443/nonexistent
# <html><body><h1>404</h1>...

# Test with openssl (see the TLS details):
echo -e "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" | \
  openssl s_client -connect 127.0.0.1:8443 -quiet 2>/dev/null

# Open in browser:
# https://127.0.0.1:8443
# You'll see a security warning (self-signed cert) — click through it.
# The page appears. The padlock shows it's encrypted.
# See the TLS details from the server's perspective:
# Add to your handle_request:
let (_, conn) = stream.get_ref();
println!("  Protocol: {:?}", conn.protocol_version());
println!("  Cipher:   {:?}", conn.negotiated_cipher_suite());

What curl’s -k flag does

Without -k:
  curl https://127.0.0.1:8443/
  → ERROR: self-signed certificate
  curl checks the cert against trusted CAs — yours isn't trusted.

With -k (--insecure):
  curl -k https://127.0.0.1:8443/
  → Works! curl skips certificate verification.
  The connection is still encrypted — just not authenticated.

With your CA cert:
  curl --cacert ca.crt https://127.0.0.1:8443/
  → Works AND verified! No warning.
  (Requires generating a CA cert and signing your server cert with it — Lesson 8)

Exercises

Exercise 1: Basic HTTPS server

Implement steps 1-5. Serve a static HTML page. Verify with curl -k.

Exercise 2: Serve static files

Serve files from a ./public/ directory:

  • GET /index.html → read ./public/index.html
  • GET /style.css → read ./public/style.css
  • Set Content-Type based on file extension (html, css, js, png, etc.)

Exercise 3: CA-signed certificate

Instead of self-signed, generate a CA cert + server cert (Lesson 8). Install the CA cert on your system:

# macOS:
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain ca.crt

# Now curl works WITHOUT -k:
curl https://127.0.0.1:8443/
# No warning! Proper padlock in browser too.

# Clean up:
sudo security remove-trusted-cert -d ca.crt

Exercise 4: Request logging

Log each request with: timestamp, client IP, method, path, response status, TLS version, cipher suite.

[2026-04-13 10:30:00] 127.0.0.1 GET / → 200 (TLS 1.3, AES-256-GCM)
[2026-04-13 10:30:01] 127.0.0.1 GET /about → 200 (TLS 1.3, AES-256-GCM)
[2026-04-13 10:30:02] 127.0.0.1 GET /missing → 404 (TLS 1.3, AES-256-GCM)

Project: TLS Scanner

Prerequisites: Lesson 13 (TLS Handshake Deep Dive), Lesson 14 (tokio-rustls). Probe a server’s TLS configuration.

What is this?

When something goes wrong with TLS (“site won’t load”, “certificate warning”, “slow handshake”), the first thing you do is scan the server. Tools like testssl.sh and SSL Labs probe a server’s TLS setup and report issues.

You’re building a simplified version:

┌──────────────────────────────────────────────────────────┐
│  What a TLS scanner checks                               │
│                                                          │
│  ✓ Protocol version — TLS 1.3? 1.2? Old 1.0? (bad!)     │
│  ✓ Cipher suite — modern AEAD? or weak RC4? (bad!)       │
│  ✓ Certificate — expired? wrong hostname? weak key?      │
│  ✓ Key exchange — ephemeral DH? (forward secrecy)        │
│  ✓ HSTS header — does the site force HTTPS?              │
│  ✓ Certificate chain — complete? trusted root?            │
└──────────────────────────────────────────────────────────┘

What you’re building

cargo run -p tls --bin p7-scanner -- scan google.com

  TLS Scan: google.com:443
  ─────────────────────────
  Protocol:     TLS 1.3 ✓
  Cipher:       TLS_AES_256_GCM_SHA384 ✓
  Key exchange: X25519 (forward secrecy ✓)

  Certificate:
    Subject:    *.google.com
    Issuer:     GTS CA 1C3
    Expires in: 62 days ✓
    Key type:   ECDSA P-256
    SANs:       *.google.com, google.com, *.youtube.com, ...

  HTTP headers:
    HSTS:       max-age=31536000 ✓

  Grade: A

Try it with existing tools

# === openssl s_client (the manual way) ===

# Protocol + cipher:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  grep -E "Protocol|Cipher|Server Temp Key"

# Certificate dates:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -dates -subject -issuer

# SANs:
echo | openssl s_client -connect google.com:443 2>/dev/null | \
  openssl x509 -noout -ext subjectAltName

# HSTS header:
curl -sI https://google.com | grep -i strict-transport

# === testssl.sh (the automated way) ===
# brew install testssl
# testssl google.com
# Test sites with known problems (badssl.com):
echo | openssl s_client -connect expired.badssl.com:443 2>/dev/null | \
  openssl x509 -noout -dates
# notAfter is in the past!

echo | openssl s_client -connect wrong.host.badssl.com:443 2>/dev/null | \
  openssl x509 -noout -subject -ext subjectAltName
# hostname doesn't match SANs

echo | openssl s_client -connect tls-v1-0.badssl.com:1010 2>/dev/null | \
  grep Protocol
# TLS 1.0 — insecure!

Implementation guide

Step 0: Project setup

touch tls/src/bin/p7-scanner.rs
#![allow(unused)]
fn main() {
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "tls-scanner", about = "Scan a server's TLS configuration")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Scan a single host
    Scan {
        host: String,
        #[arg(long, default_value = "443")]
        port: u16,
    },
    /// Check certificate expiry for multiple hosts
    Expiry {
        hosts: Vec<String>,
    },
}
}

Step 1: Connect and extract TLS info

Reuse the TLS connection code from P4 (Certificate Inspector):

#![allow(unused)]
fn main() {
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use rustls::{ClientConfig, RootCertStore};

async fn tls_connect(host: &str, port: u16)
    -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error>>
{
    let mut root_store = RootCertStore::empty();
    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());

    let config = ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_no_client_auth();

    let connector = TlsConnector::from(Arc::new(config));
    let tcp = TcpStream::connect(format!("{host}:{port}")).await?;
    let server_name = host.try_into()?;
    Ok(connector.connect(server_name, tcp).await?)
}
}

After connecting, extract the negotiated parameters:

#![allow(unused)]
fn main() {
let tls = tls_connect(host, port).await?;
let (_, conn) = tls.get_ref();

let protocol = conn.protocol_version().unwrap();
let cipher = conn.negotiated_cipher_suite().unwrap();
let certs = conn.peer_certificates().unwrap();

println!("Protocol: {:?}", protocol);
println!("Cipher:   {:?}", cipher);
println!("Chain:    {} certificates", certs.len());
}

Test:

cargo run -p tls --bin p7-scanner -- scan google.com
# Protocol: TLSv1_3
# Cipher: TLS13_AES_256_GCM_SHA384
# Chain: 3 certificates

Step 2: Parse the certificate

Reuse the x509 parsing from P4:

#![allow(unused)]
fn main() {
use x509_parser::prelude::*;

fn analyze_cert(der: &[u8], index: usize) {
    let (_, cert) = X509Certificate::from_der(der).unwrap();

    println!("  [{}] {}", index, cert.subject());
    println!("      Issuer:  {}", cert.issuer());

    // Expiry
    let not_after = cert.validity().not_after.timestamp();
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
    let days = (not_after - now) / 86400;

    if days < 0 {
        println!("      Expires:  EXPIRED {} days ago ✗", -days);
    } else if days < 30 {
        println!("      Expires:  {} days (renew soon!) ⚠", days);
    } else {
        println!("      Expires:  {} days ✓", days);
    }

    // SANs
    if let Ok(Some(san)) = cert.subject_alternative_name() {
        let names: Vec<_> = san.value.general_names.iter()
            .filter_map(|n| match n {
                x509_parser::extensions::GeneralName::DNSName(d) => Some(d.to_string()),
                _ => None,
            })
            .collect();
        if !names.is_empty() {
            println!("      SANs:    {}", names[..names.len().min(5)].join(", "));
            if names.len() > 5 {
                println!("               ... and {} more", names.len() - 5);
            }
        }
    }
}
}

Step 3: Check HTTP security headers

After the TLS handshake, send a minimal HTTP request and check the response headers:

#![allow(unused)]
fn main() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn check_http_headers(tls: &mut tokio_rustls::client::TlsStream<TcpStream>, host: &str) {
    let request = format!(
        "GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
    );
    tls.write_all(request.as_bytes()).await.unwrap();

    let mut response = vec![0u8; 8192];
    let n = tls.read(&mut response).await.unwrap_or(0);
    let response = String::from_utf8_lossy(&response[..n]);

    // Check HSTS
    if let Some(hsts) = response.lines().find(|l| l.to_lowercase().starts_with("strict-transport-security")) {
        println!("  HSTS:     {hsts} ✓");
    } else {
        println!("  HSTS:     not set ✗");
    }

    // Check other security headers
    for header in ["x-content-type-options", "x-frame-options", "content-security-policy"] {
        if response.lines().any(|l| l.to_lowercase().starts_with(header)) {
            println!("  {}: set ✓", header);
        }
    }
}
}

Step 4: Grade the result

Assign a simple letter grade:

#![allow(unused)]
fn main() {
fn grade(protocol_ok: bool, cipher_ok: bool, cert_days: i64, has_hsts: bool) -> &'static str {
    if !protocol_ok || cert_days < 0 { return "F"; }
    if !cipher_ok { return "C"; }
    if cert_days < 30 { return "B"; }
    if !has_hsts { return "B+"; }
    "A"
}
}

Step 5: Batch expiry checker

#![allow(unused)]
fn main() {
async fn check_expiry(hosts: Vec<String>) {
    println!("{:<30} {:>6}  {}", "Host", "Days", "Status");
    println!("{}", "─".repeat(50));

    for host in &hosts {
        match tls_connect(host, 443).await {
            Ok(tls) => {
                let (_, conn) = tls.get_ref();
                if let Some(certs) = conn.peer_certificates() {
                    let (_, cert) = X509Certificate::from_der(&certs[0]).unwrap();
                    let not_after = cert.validity().not_after.timestamp();
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
                    let days = (not_after - now) / 86400;
                    let status = if days < 0 { "EXPIRED ✗" }
                        else if days < 30 { "RENEW SOON ⚠" }
                        else { "✓" };
                    println!("{:<30} {:>6}  {}", host, days, status);
                }
            }
            Err(e) => println!("{:<30} {:>6}  ERROR: {}", host, "-", e),
        }
    }
}
}

Step 6: Test it

# Scan good sites:
cargo run -p tls --bin p7-scanner -- scan google.com
cargo run -p tls --bin p7-scanner -- scan github.com
cargo run -p tls --bin p7-scanner -- scan cloudflare.com

# Scan problematic sites:
cargo run -p tls --bin p7-scanner -- scan expired.badssl.com
cargo run -p tls --bin p7-scanner -- scan self-signed.badssl.com

# Batch expiry check:
cargo run -p tls --bin p7-scanner -- expiry google.com github.com cloudflare.com
# Host                            Days  Status
# ──────────────────────────────────────────────
# google.com                        62  ✓
# github.com                       198  ✓
# cloudflare.com                   150  ✓

Exercises

Exercise 1: Basic scanner

Implement steps 1-4. Scan google.com and display protocol, cipher, certificate details, grade.

Exercise 2: Batch expiry checker

Scan a list of domains, report days until cert expiry. Color-code: green >30, yellow <30, red expired.

Exercise 3: JSON output

Add --json flag for machine-readable output:

cargo run -p tls --bin p7-scanner -- scan --json google.com

Exercise 4: Compare sites

Scan multiple sites side-by-side in a table:

Domain           Protocol  Cipher           Expires  HSTS  Grade
google.com       TLS 1.3   AES-256-GCM      62 days  ✓     A
example.com      TLS 1.3   AES-128-GCM      90 days  ✗     B+
old-site.com     TLS 1.2   AES-128-CBC      EXPIRED  ✗     F

Project: Certificate Authority

Prerequisites: Lesson 7 (Certificates), Lesson 8 (Certificate Generation with rcgen).

What is this?

A Certificate Authority (CA) issues certificates. Let’s Encrypt is a public CA — it issues certs for the whole internet. But companies also run private CAs for internal services: microservices, databases, CI servers, dev environments.

You’re building a private CA as a CLI tool:

# Initialize the CA (do once):
cargo run -p tls --bin p8-ca -- init --name "My Company CA"
# Created: ca.key (KEEP SECRET), ca.crt (distribute to clients)

# Issue a server certificate:
cargo run -p tls --bin p8-ca -- issue --domain api.internal.com --days 90
# Created: api.internal.com.key, api.internal.com.crt
# Signed by: My Company CA
# Expires: 2026-07-13

# Issue another:
cargo run -p tls --bin p8-ca -- issue --domain db.internal.com --days 90

# List all issued certs:
cargo run -p tls --bin p8-ca -- list
# api.internal.com   expires 2026-07-13  VALID
# db.internal.com    expires 2026-07-13  VALID

# Verify a cert:
openssl verify -CAfile ca.crt api.internal.com.crt
# api.internal.com.crt: OK

# Revoke a cert:
cargo run -p tls --bin p8-ca -- revoke api.internal.com
# Revoked.

Architecture

┌────────────────────────────────────────────────────────────┐
│  Your CA                                                   │
│                                                            │
│  Files:                                                    │
│    ca.key           ← CA private key (PROTECT THIS!)       │
│    ca.crt           ← CA certificate (distribute to all)   │
│    ca-db.json       ← database of issued certs             │
│    certs/           ← directory of issued certs + keys     │
│                                                            │
│  Commands:                                                 │
│    init             → create CA key pair + self-signed cert │
│    issue --domain   → generate key + cert, sign with CA    │
│    list             → show all issued certs + status        │
│    revoke --domain  → mark a cert as revoked               │
│    verify --cert    → check if a cert is valid + not revoked│
└────────────────────────────────────────────────────────────┘

Implementation guide

Step 0: Project setup

mkdir -p tls/src/bin
touch tls/src/bin/p8-ca.rs
#![allow(unused)]
fn main() {
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "mini-ca", about = "Private Certificate Authority")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Initialize a new CA
    Init {
        #[arg(long, default_value = "My CA")]
        name: String,
    },
    /// Issue a server certificate
    Issue {
        #[arg(long)]
        domain: String,
        #[arg(long, default_value = "90")]
        days: u32,
    },
    /// List all issued certificates
    List,
    /// Revoke a certificate
    Revoke {
        #[arg(long)]
        domain: String,
    },
}
}

Step 1: Initialize the CA

Generate a self-signed CA certificate using rcgen (Lesson 8):

#![allow(unused)]
fn main() {
use rcgen::{CertificateParams, IsCa, BasicConstraints, KeyPair};

fn init_ca(name: &str) {
    let mut params = CertificateParams::new(vec![name.into()]).unwrap();
    params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);

    let ca_key = KeyPair::generate().unwrap();
    let ca_cert = params.self_signed(&ca_key).unwrap();

    std::fs::write("ca.key", ca_key.serialize_pem()).unwrap();
    std::fs::write("ca.crt", ca_cert.pem()).unwrap();
    std::fs::write("ca-db.json", "[]").unwrap();
    std::fs::create_dir_all("certs").ok();

    println!("CA initialized: {name}");
    println!("  ca.key — KEEP THIS SECRET");
    println!("  ca.crt — distribute to all clients");
}
}

Test:

cargo run -p tls --bin p8-ca -- init --name "Acme Corp CA"
ls -la ca.key ca.crt ca-db.json
openssl x509 -in ca.crt -text -noout | head -15
# Issuer: CN = Acme Corp CA
# Subject: CN = Acme Corp CA  (same = self-signed)
# X509v3 Basic Constraints: critical
#     CA:TRUE

Step 2: Issue a server certificate

Load the CA key + cert, generate a new server key + cert, sign it:

#![allow(unused)]
fn main() {
fn issue_cert(domain: &str, days: u32) {
    // Load CA
    let ca_key_pem = std::fs::read_to_string("ca.key").expect("Run 'init' first");
    let ca_cert_pem = std::fs::read_to_string("ca.crt").unwrap();
    let ca_key = KeyPair::from_pem(&ca_key_pem).unwrap();
    let ca_cert_params = CertificateParams::from_ca_cert_pem(&ca_cert_pem).unwrap();
    let ca_cert = ca_cert_params.self_signed(&ca_key).unwrap();

    // Generate server cert
    let mut params = CertificateParams::new(vec![domain.into()]).unwrap();
    params.is_ca = IsCa::NoCa;
    // Set validity (rcgen uses time crate)

    let server_key = KeyPair::generate().unwrap();
    let server_cert = params.signed_by(&server_key, &ca_cert, &ca_key).unwrap();

    // Save
    let key_path = format!("certs/{domain}.key");
    let cert_path = format!("certs/{domain}.crt");
    std::fs::write(&key_path, server_key.serialize_pem()).unwrap();
    std::fs::write(&cert_path, server_cert.pem()).unwrap();

    // Update database
    // ... append to ca-db.json ...

    println!("Issued: {domain}");
    println!("  Key:  {key_path}");
    println!("  Cert: {cert_path}");
}
}

Test:

cargo run -p tls --bin p8-ca -- issue --domain api.internal.com
openssl verify -CAfile ca.crt certs/api.internal.com.crt
# api.internal.com.crt: OK

openssl x509 -in certs/api.internal.com.crt -text -noout | head -15
# Issuer: CN = Acme Corp CA  ← signed by your CA
# Subject: CN = api.internal.com

Step 3: Certificate database

Track all issued certs in a JSON file:

#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct CertRecord {
    domain: String,
    issued: String,   // ISO date
    expires: String,  // ISO date
    serial: u64,
    revoked: bool,
}
}

Step 4: List and revoke

cargo run -p tls --bin p8-ca -- list
# Domain              Issued      Expires     Status
# api.internal.com    2026-04-13  2026-07-12  VALID
# db.internal.com     2026-04-13  2026-07-12  VALID

cargo run -p tls --bin p8-ca -- revoke --domain api.internal.com
# Revoked: api.internal.com

cargo run -p tls --bin p8-ca -- list
# Domain              Issued      Expires     Status
# api.internal.com    2026-04-13  2026-07-12  REVOKED
# db.internal.com     2026-04-13  2026-07-12  VALID

Step 5: Use with tokio-rustls

This is the payoff — use your CA-issued certs in a real TLS server:

#![allow(unused)]
fn main() {
// Server loads its cert:
let config = ServerConfig::builder()
    .with_no_client_auth()
    .with_single_cert(server_certs, server_key)?;

// Client trusts your CA:
let mut root_store = RootCertStore::empty();
let ca_cert = std::fs::read("ca.crt")?;
root_store.add(CertificateDer::from(ca_cert))?;
let config = ClientConfig::builder()
    .with_root_certificates(root_store)
    .with_no_client_auth();

// Handshake succeeds — your CA is trusted!
}

Exercises

Exercise 1: Basic CA

Implement init + issue. Verify with openssl verify -CAfile ca.crt server.crt.

Exercise 2: Use with P6 (HTTPS Server)

Start the HTTPS server (P6) using your CA-issued cert instead of a self-signed one. Configure curl to trust your CA: curl --cacert ca.crt https://localhost:8443/. No more -k!

Exercise 3: Intermediate CA

Create a 3-level chain: Root CA → Intermediate CA → Server cert. Verify with:

openssl verify -CAfile root.crt -untrusted intermediate.crt server.crt

Exercise 4: Certificate renewal

Add a renew command that issues a new cert for an existing domain (new key pair, new expiry) and marks the old one as superseded in the database.

Project: mTLS Service Mesh

Prerequisites: Lesson 11 (mTLS), P8 (Certificate Authority).

What is this?

In a microservice architecture, services talk to each other over the network. Without mTLS, any process that can reach the network can call any service. With mTLS, both sides prove their identity — unauthorized services are rejected.

┌──────────────────────────────────────────────────────────┐
│  Without mTLS:                                           │
│    Any process can call the payment service.             │
│    A compromised pod sends: POST /charge $10,000         │
│    Payment service: "OK!" (no identity check)            │
│                                                          │
│  With mTLS:                                              │
│    Payment service requires a client certificate.        │
│    Compromised pod has no valid cert → TLS handshake     │
│    fails → request never reaches the application.        │
│    Only "order-service" (with its cert) can call it.     │
└──────────────────────────────────────────────────────────┘

This is how Istio, Linkerd, and corporate zero-trust networks work.

What you’re building

Two services that authenticate each other:

┌──────────────────┐          mTLS          ┌──────────────────┐
│  Order Service   │ ◄─────────────────────► │  Payment Service │
│  cert: order.crt │  Both present certs     │  cert: payment.crt│
│  key:  order.key │  Both verify against CA │  key:  payment.key│
└──────────────────┘                         └──────────────────┘
              Both signed by: Company CA (from P8)
# Terminal 1 — Payment service (requires client cert):
cargo run -p tls --bin p9-mesh -- payment --port 9001 \
  --cert certs/payment.crt --key certs/payment.key --ca ca.crt

# Terminal 2 — Order service (presents its cert as client):
cargo run -p tls --bin p9-mesh -- order --payment-host 127.0.0.1:9001 \
  --cert certs/order.crt --key certs/order.key --ca ca.crt
# Connected to payment service ✓ (peer: CN=payment.internal)
# Charging $50... OK

# Terminal 3 — Unauthorized service (no cert):
cargo run -p tls --bin p9-mesh -- order --payment-host 127.0.0.1:9001 \
  --ca ca.crt
# ERROR: TLS handshake failed — client certificate required

Implementation guide

Step 1: Issue certs with your CA (from P8)

cargo run -p tls --bin p8-ca -- issue --domain order.internal
cargo run -p tls --bin p8-ca -- issue --domain payment.internal

Step 2: Server that requires client cert

The key difference from P6: with_client_cert_verifier instead of with_no_client_auth:

#![allow(unused)]
fn main() {
use rustls::server::WebPkiClientVerifier;

let ca_certs = load_ca_certs("ca.crt");
let client_verifier = WebPkiClientVerifier::builder(Arc::new(ca_certs))
    .build()
    .unwrap();

let config = ServerConfig::builder()
    .with_client_cert_verifier(client_verifier)  // ← requires client cert!
    .with_single_cert(server_certs, server_key)?;
}

Step 3: Client that presents its cert

#![allow(unused)]
fn main() {
let config = ClientConfig::builder()
    .with_root_certificates(ca_root_store)
    .with_client_auth_cert(client_certs, client_key)?;  // ← presents cert!
}

Step 4: Extract peer identity

After the handshake, read WHO connected:

#![allow(unused)]
fn main() {
let (_, conn) = tls_stream.get_ref();
let peer_certs = conn.peer_certificates().unwrap();
let (_, cert) = X509Certificate::from_der(&peer_certs[0]).unwrap();
println!("Peer: {}", cert.subject());
// Use the subject for authorization: "Only order.internal can call /charge"
}

Step 5: Authorization

Authentication tells you WHO. Authorization tells you WHAT they can do:

#![allow(unused)]
fn main() {
let peer_name = extract_cn(&peer_certs[0]); // "order.internal"

match (method, path) {
    ("POST", "/charge") if peer_name == "order.internal" => {
        // Allowed — order service can charge
    }
    ("POST", "/charge") => {
        // Denied — wrong service
        respond_403("Not authorized");
    }
    _ => respond_404(),
}
}

Exercises

Exercise 1: Two-service mTLS

Set up order + payment services. Order calls payment. Both verify certs. Show that an unauthorized service is rejected.

Exercise 2: Service identity extraction

After the handshake, extract and log the peer’s certificate CN. Use it for authorization decisions.

Exercise 3: Three services

Add a third service (inventory). Order calls both payment and inventory. Each service only accepts calls from authorized peers:

  • Payment accepts calls from: order
  • Inventory accepts calls from: order
  • Order accepts calls from: nobody (it’s the entry point)

Exercise 4: Certificate rotation

Reissue a service’s cert (from P8). The service picks up the new cert without restarting. Other services continue to trust it (same CA).

Project: TLS Termination Proxy

Prerequisites: Lesson 14 (tokio-rustls), P8 (Certificate Authority).

What is this?

A TLS termination proxy sits in front of your backend services. It handles all the TLS complexity — certificates, handshakes, encryption — so your backends can stay simple (plain HTTP).

This is what nginx, HAProxy, Cloudflare, and AWS ALB do for millions of websites.

With TLS termination proxy:

  Internet              Proxy                   Backend
  (encrypted)           (terminates TLS)        (plaintext)
  │                     │                       │
  │── HTTPS ──────────►│── HTTP ──────────────►│
  │   (encrypted)       │   (plaintext, fast)   │  Backend is simple.
  │◄── HTTPS ──────────│◄── HTTP ──────────────│  No cert management.
  │                     │                       │  No TLS overhead.

Without proxy:

  Internet              Backend
  │                     │
  │── HTTPS ──────────►│  Backend must:
  │                     │  - Manage certificates
  │                     │  - Handle TLS handshakes
  │                     │  - Renew certs
  │                     │  - Each of 20 services does this independently

What you’re building

# Start a plaintext backend:
python3 -m http.server 8000 &

# Start the TLS proxy:
cargo run -p tls --bin p10-proxy -- \
  --listen 0.0.0.0:8443 \
  --backend 127.0.0.1:8000 \
  --cert server.crt --key server.key

# Client connects with HTTPS:
curl -k https://127.0.0.1:8443/
# Gets the response from the backend — but over TLS!

Try it with existing tools

# nginx does this with ~5 lines of config:
# server {
#     listen 443 ssl;
#     ssl_certificate server.crt;
#     ssl_certificate_key server.key;
#     location / { proxy_pass http://127.0.0.1:8000; }
# }
# You're building this in Rust.

Implementation guide

Step 0: Project setup

touch tls/src/bin/p10-proxy.rs
#![allow(unused)]
fn main() {
use clap::Parser;

#[derive(Parser)]
#[command(name = "tls-proxy", about = "TLS termination proxy")]
struct Cli {
    /// Address to listen on (e.g., 0.0.0.0:8443)
    #[arg(long)]
    listen: String,
    /// Backend address (e.g., 127.0.0.1:8000)
    #[arg(long)]
    backend: String,
    /// TLS certificate path
    #[arg(long)]
    cert: String,
    /// TLS private key path
    #[arg(long)]
    key: String,
}
}

Step 1: Accept TLS connections

Same as P6, but instead of handling HTTP yourself, you forward to the backend:

#![allow(unused)]
fn main() {
let acceptor = TlsAcceptor::from(tls_config);
let listener = TcpListener::bind(&cli.listen).await?;

loop {
    let (client_tcp, addr) = listener.accept().await?;
    let acceptor = acceptor.clone();
    let backend = cli.backend.clone();

    tokio::spawn(async move {
        let client_tls = acceptor.accept(client_tcp).await.unwrap();
        proxy_connection(client_tls, &backend).await;
    });
}
}

Step 2: Connect to backend and pipe data

The core of the proxy — just copy bytes both ways:

#![allow(unused)]
fn main() {
async fn proxy_connection(
    mut client: tokio_rustls::server::TlsStream<TcpStream>,
    backend_addr: &str,
) {
    let mut backend = TcpStream::connect(backend_addr).await.unwrap();

    // Pipe both directions:
    // client (encrypted) → proxy (decrypts) → backend (plaintext)
    // backend (plaintext) → proxy (encrypts) → client (encrypted)
    tokio::io::copy_bidirectional(&mut client, &mut backend).await.ok();
}
}

That’s it. copy_bidirectional handles both directions. rustls transparently decrypts/encrypts.

Step 3: Test it

# Start a backend:
python3 -m http.server 8000 &

# Start the proxy:
cargo run -p tls --bin p10-proxy -- \
  --listen 127.0.0.1:8443 \
  --backend 127.0.0.1:8000 \
  --cert certs/server.crt --key certs/server.key

# Test:
curl -k https://127.0.0.1:8443/
# Shows the same content as http://127.0.0.1:8000/ — but encrypted!

# Verify TLS:
echo | openssl s_client -connect 127.0.0.1:8443 2>/dev/null | grep -E "Protocol|Cipher"

Step 4: Add logging

#![allow(unused)]
fn main() {
async fn proxy_connection(mut client: TlsStream<TcpStream>, backend_addr: &str, addr: SocketAddr) {
    let (_, conn) = client.get_ref();
    let proto = conn.protocol_version().map(|p| format!("{p:?}")).unwrap_or("?".into());
    let cipher = conn.negotiated_cipher_suite().map(|c| format!("{c:?}")).unwrap_or("?".into());

    let mut backend = TcpStream::connect(backend_addr).await.unwrap();
    println!("[{addr}] connected ({proto}, {cipher})");

    let result = tokio::io::copy_bidirectional(&mut client, &mut backend).await;
    match result {
        Ok((c2b, b2c)) => println!("[{addr}] done ({c2b} → backend, {b2c} ← backend)"),
        Err(e) => println!("[{addr}] error: {e}"),
    }
}
}

Exercises

Exercise 1: Basic proxy

Implement steps 1-3. Proxy HTTPS to a local HTTP server.

Exercise 2: X-Forwarded-For header

The backend doesn’t know the real client IP (it sees the proxy’s IP). Inject X-Forwarded-For by reading the first HTTP request, adding the header, then forwarding:

Client → Proxy → Backend
                  sees: X-Forwarded-For: 192.168.1.100

This requires parsing the HTTP request before forwarding — you can’t use copy_bidirectional directly.

Exercise 3: SNI-based routing

Multiple backends behind one proxy, routed by domain name:

api.example.com  → 127.0.0.1:8001
web.example.com  → 127.0.0.1:8002

Read the SNI from the TLS ClientHello before completing the handshake. Route to the matching backend.

Exercise 4: Health checks

Periodically check if backends are alive. If a backend is down, return 502 Bad Gateway instead of hanging.

Project: HTTPS Intercepting Proxy

Prerequisites: Lesson 8 (cert generation), Lesson 13 (handshake), Lesson 14 (tokio-rustls), P8 (CA). The ultimate TLS capstone.

What is this?

When you’re debugging API calls, you need to see the actual HTTP requests and responses — but they’re encrypted with TLS. Tools like mitmproxy, Charles Proxy, and Fiddler solve this by intercepting HTTPS traffic.

This project uses every TLS concept you’ve learned:

Concept you learned          How it's used here
──────────────────────────────────────────────────────
Lesson 2: Encryption         Decrypt client traffic, re-encrypt to server
Lesson 3: Signatures         Sign fake certificates
Lesson 7: Certificates       Understand what makes a cert trusted
Lesson 8: Cert generation    Generate fake certs on-the-fly for any domain
Lesson 13: TLS handshake     Two handshakes — one with client, one with server
Lesson 14: tokio-rustls      Real TLS for both connections
P8: Certificate Authority    Your CA signs the fake certs

Try it with existing tools

# Install mitmproxy to see how it works:
# macOS: brew install mitmproxy
# Linux: pip3 install mitmproxy

# Start it:
mitmproxy --listen-port 8080

# In another terminal, use it as a proxy:
curl -x http://127.0.0.1:8080 -k https://example.com
# mitmproxy shows: GET https://example.com/ → 200 (1256 bytes)
# It decrypted the HTTPS traffic!

# How? It generated a fake cert for example.com, signed by ~/.mitmproxy/mitmproxy-ca-cert.pem
ls ~/.mitmproxy/
# mitmproxy-ca-cert.pem  ← the CA cert (install this to avoid warnings)

You’re building the same thing from scratch.

What you’re building

A proxy that sits between a client and any HTTPS server, decrypting all traffic so you can inspect it — like a mini mitmproxy.

# Start your proxy:
cargo run -p tls --bin p11-intercept -- --port 8080

# Use it:
curl -x http://127.0.0.1:8080 --cacert ca.crt https://example.com
# Output (from proxy):
#   [CONNECT] example.com:443
#   [→] GET / HTTP/1.1
#       Host: example.com
#   [←] HTTP/1.1 200 OK
#       Content-Type: text/html
#       Content-Length: 1256
#       <html>...</html>
Browser                 Your Proxy              Real Server
  │                        │                       │
  │── CONNECT google.com:443 ─►│                   │
  │                        │                       │
  │◄── 200 Connection     │                       │
  │    Established         │                       │
  │                        │                       │
  │── TLS handshake ─────►│                       │
  │   (proxy's fake cert   │── TLS handshake ────►│
  │    for google.com)     │   (real cert)         │
  │                        │                       │
  │── GET / (encrypted) ──►│── GET / (re-encrypted)►│
  │                        │                       │
  │   Proxy sees plaintext │                       │
  │   request + response!  │                       │

How it works

The magic: on-the-fly certificate generation.

1. Client says: "I want to connect to google.com"  (HTTP CONNECT)
2. Proxy says: "OK" (200 Connection Established)
3. Client starts TLS handshake, sends ClientHello with SNI=google.com
4. Proxy reads SNI, generates a FAKE certificate for google.com
   → signed by YOUR CA (installed on the client machine)
5. Client verifies cert → chain leads to YOUR CA → trusted!
6. Proxy connects to REAL google.com with real TLS
7. Proxy decrypts client traffic, logs it, re-encrypts to real server
8. Both sides think they have a private connection — but the proxy sees everything

Why the client trusts the fake cert

Normal:
  Browser trusts: DigiCert, Let's Encrypt, ... (100+ root CAs)
  google.com shows cert signed by Google Trust Services → trusted ✓

With intercepting proxy:
  Browser trusts: DigiCert, Let's Encrypt, ..., YOUR CA
  Proxy shows cert for google.com signed by YOUR CA → trusted ✓

  You must install YOUR CA in the browser/OS trust store.
  Without it, the browser shows a certificate warning.

Architecture

┌─────────────────────────────────────────────────────────────┐
│  Intercepting Proxy                                         │
│                                                             │
│  ┌──────────────────┐                                       │
│  │  CA Key + Cert    │  (generated once, installed on client)│
│  └────────┬─────────┘                                       │
│           │                                                 │
│           ▼                                                 │
│  ┌──────────────────┐     ┌──────────────────┐              │
│  │  Cert Generator   │     │  TLS Client      │              │
│  │  (rcgen)          │     │  (to real server) │              │
│  │                   │     │                   │              │
│  │  SNI: google.com  │     │  connect to       │              │
│  │  → fake cert for  │     │  google.com:443   │              │
│  │    google.com     │     │  (real TLS)       │              │
│  └──────────────────┘     └──────────────────┘              │
│           │                         │                       │
│           ▼                         ▼                       │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Request/Response Logger                              │   │
│  │                                                       │   │
│  │  → GET /search?q=hello HTTP/1.1                       │   │
│  │  ← 200 OK (text/html, 15KB)                          │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Implementation guide

Step 1: HTTP CONNECT proxy

Listen for CONNECT host:port requests. Respond with 200. Then tunnel the connection.

#![allow(unused)]
fn main() {
// Read the CONNECT request
let mut buf = [0u8; 4096];
let n = client.read(&mut buf).await?;
let request = String::from_utf8_lossy(&buf[..n]);
// "CONNECT google.com:443 HTTP/1.1\r\n..."
let host = parse_connect_host(&request); // "google.com"

client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
}

Step 2: Generate fake certificate

#![allow(unused)]
fn main() {
use rcgen::{CertificateParams, KeyPair, IsCa};

fn generate_cert_for_host(host: &str, ca_cert: &Certificate, ca_key: &KeyPair) -> (CertificateDer, PrivateKeyDer) {
    let mut params = CertificateParams::new(vec![host.into()])?;
    params.is_ca = IsCa::NoCa;
    let key = KeyPair::generate()?;
    let cert = params.signed_by(&key, ca_cert, ca_key)?;
    (cert.der().clone(), key.serialize_der())
}
}

Step 3: TLS to client (with fake cert)

#![allow(unused)]
fn main() {
let server_config = ServerConfig::builder()
    .with_no_client_auth()
    .with_single_cert(vec![fake_cert], fake_key)?;
let mut client_tls = TlsAcceptor::from(Arc::new(server_config))
    .accept(client).await?;
}

Step 4: TLS to real server

#![allow(unused)]
fn main() {
let mut real_tls = TlsConnector::from(Arc::new(client_config))
    .connect(host.try_into()?, real_tcp).await?;
}

Step 5: Pipe and log

#![allow(unused)]
fn main() {
// Read from client, log, forward to server
// Read from server, log, forward to client
}

Setting up the CA trust

# Generate CA:
cargo run -p tls --bin p8-ca -- init

# macOS: install CA cert
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain ca.crt

# Linux: install CA cert
sudo cp ca.crt /usr/local/share/ca-certificates/proxy-ca.crt
sudo update-ca-certificates

# For curl:
curl --cacert ca.crt https://google.com --proxy http://127.0.0.1:8080

# Remove when done:
# macOS: sudo security remove-trusted-cert -d ca.crt
# Linux: sudo rm /usr/local/share/ca-certificates/proxy-ca.crt && sudo update-ca-certificates

Ethical note

This tool is for debugging your own traffic and authorized security testing only. Intercepting someone else’s traffic without consent is illegal in most jurisdictions.

Legitimate uses:

  • Debugging API calls during development
  • Security testing your own services
  • Corporate network monitoring (with employee consent)
  • CTF challenges and security research

Exercises

Exercise 1: Basic CONNECT proxy

Handle CONNECT, tunnel bytes without decryption. Verify with curl --proxy http://127.0.0.1:8080 https://example.com.

Exercise 2: TLS interception

Generate fake certs, decrypt traffic, log HTTP requests/responses. Verify with curl --proxy ... --cacert ca.crt https://example.com.

Exercise 3: Certificate caching

Cache generated certificates so you don’t regenerate for every connection to the same host. Use a HashMap<String, (CertificateDer, PrivateKeyDer)>.

Exercise 4: Web UI

Add a web dashboard at http://127.0.0.1:8081 that shows all intercepted requests in real-time (like mitmproxy’s web interface).

Design Challenges

These challenges test your ability to combine cryptographic primitives into real solutions. There’s no single correct answer — the goal is to think through the design, identify trade-offs, and build a working prototype.

How to approach each challenge

  1. Read the scenario — understand what the system needs to do
  2. List the requirements — what properties must hold (confidentiality, integrity, authentication?)
  3. Pick your primitives — which lessons apply? (hashing, encryption, signatures, key exchange, certificates?)
  4. Draw the protocol — what messages are exchanged? What does each party store?
  5. Implement — build a working prototype in Rust
  6. Break it — try to attack your own design. What assumptions did you make?

Beginner (after Phases 1-2)

Challenge 1: Tamper-Proof Audit Log

Scenario: You run a financial service. Regulators require that your audit logs cannot be modified after the fact. An employee with database access should not be able to silently delete or change a log entry.

Requirements:

  • Each log entry contains: timestamp, action, user, data
  • Each entry is linked to the previous one (if any entry is modified, everything after it becomes invalid)
  • Anyone can verify the log hasn’t been tampered with

Constraints:

  • The log is stored in a plain file or database (no special hardware)
  • You cannot use a blockchain network
Hints
  • Lesson 1 (Hashing): each entry includes the hash of the previous entry
  • This creates a hash chain — modifying entry N changes its hash, which breaks entry N+1’s chain
  • This is how Git works internally (each commit hashes the previous commit)
  • This is also the core idea behind blockchains

Design questions:

  • What happens if the attacker modifies the LAST entry? (There’s no next entry to break)
  • How could you publish the latest hash somewhere immutable (a tweet, a newspaper ad, a blockchain)?
  • What if you need to verify just one entry without checking the entire chain? (→ Merkle tree)

Verification: modify any entry in the middle of the log. Show that verification fails from that point forward.


Challenge 2: Password Breach Checker

Scenario: You want to check if your password appeared in a data breach (like haveibeenpwned.com). But you don’t want to send your password to anyone — not even the checking service.

Requirements:

  • The service has a database of 600 million breached password hashes
  • You check if your password is breached WITHOUT revealing it to the service
  • The service doesn’t learn your password, even partially

Constraints:

  • You cannot download the entire 600M hash database (it’s 30GB)
  • You must send something to the service
Hints
  • Lesson 1 (Hashing): hash your password with SHA-1 (that’s what haveibeenpwned uses)
  • Send only the first 5 hex characters of the hash (k-anonymity)
  • The service returns ALL hashes that start with those 5 characters (~500 hashes)
  • You check locally if your full hash is in the returned list

Design questions:

  • The service sees the first 5 chars of your hash. How much does this reveal? (1/16^5 = 1 in ~1M)
  • Could the service be malicious and log the prefix + your IP to narrow it down?
  • Try it yourself: curl https://api.pwnedpasswords.com/range/$(echo -n "password" | shasum -a 1 | cut -c1-5)

Challenge 3: Sealed Bid Auction

Scenario: Three companies are bidding on a government contract. Each must submit a bid without knowing the others’ bids. After the deadline, all bids are revealed simultaneously. No one can change their bid after seeing others’.

Requirements:

  • Bids are secret until the reveal phase
  • No bid can be changed after submission
  • Everyone can verify that the revealed bid matches the committed bid

Constraints:

  • There’s no trusted third party
  • The reveal happens on a public channel
Hints
  • Lesson 1 (Hashing): commitment scheme — commit = hash(bid + random_nonce)
  • Each bidder publishes their commitment before the deadline
  • After deadline, each reveals bid + nonce
  • Everyone verifies: hash(bid + nonce) == commitment

Design questions:

  • Why is the random nonce needed? (Without it, someone could hash all possible bid amounts and find yours)
  • What if a bidder refuses to reveal? (They forfeit, but the auction can still proceed)
  • What if two bidders collude — one reveals first, the other adjusts?

Challenge 4: Secure Dead Drop

Scenario: Alice wants to leave an encrypted message for Bob on a public server (like a pastebin). They’ve never communicated before, but Bob has published his public key on his personal website.

Requirements:

  • Only Bob can read the message
  • The server cannot read the message
  • Alice doesn’t need Bob to be online when she sends

Constraints:

  • No real-time key exchange (Bob is offline)
  • The message sits on a public server anyone can see
Hints
  • Lesson 4 (Key Exchange): Alice generates an ephemeral X25519 key pair
  • Alice computes shared_secret = DH(alice_ephemeral_secret, bob_public)
  • Lesson 5 (HKDF): derive encryption key from shared secret
  • Lesson 2 (Encryption): encrypt the message with ChaCha20-Poly1305
  • Alice posts: her ephemeral public key + encrypted message
  • Bob computes: shared_secret = DH(bob_secret, alice_ephemeral_public)
  • Bob derives the same key and decrypts

Design questions:

  • This is called ECIES (Elliptic Curve Integrated Encryption Scheme). Where is it used in practice?
  • What if Alice wants Bob to know the message is from her? (Add a signature — Lesson 3)
  • What if the server modifies the ephemeral public key? (Bob derives wrong key → decryption fails → integrity via AEAD)

Challenge 5: File Deduplication Without Reading Content

Scenario: You’re building a cloud storage service. When two users upload the same file, you want to store it only once (deduplication). But you want to detect duplicates WITHOUT the server reading file contents.

Requirements:

  • Server detects duplicate files
  • Server never sees file contents (files are encrypted client-side)
  • Different users uploading the same file → stored once

Constraints:

  • Files are encrypted before upload
  • Random encryption keys would make identical files look different
Hints
  • Lesson 1 (Hashing): derive the encryption key FROM the file content: key = HKDF(SHA-256(file_content))
  • Same file → same hash → same key → same ciphertext
  • Upload: (file_hash, encrypted_content) — server deduplicates by hash
  • This is called convergent encryption

Design questions:

  • Security problem: if the server knows the file might be “secret-document.pdf”, they can hash it and check. How bad is this?
  • What about files that differ by one byte? No dedup possible.
  • Real-world: Dropbox uses a form of this. What are the privacy implications?

Intermediate (after Phase 3)

Challenge 6: Multi-Signature Contract

Scenario: A company requires 3 of 5 board members to sign a document before it’s valid (like a multi-sig Bitcoin wallet). No single person can approve alone.

Requirements:

  • 5 board members each have an Ed25519 key pair
  • A document is valid only with 3+ signatures
  • Anyone can verify the document is properly signed

Constraints:

  • No trusted central server
  • Board members sign independently (not at the same time)
Hints
  • Lesson 3 (Signatures): each member signs the document independently
  • Attach all signatures to the document: [(pubkey_1, sig_1), (pubkey_2, sig_2), ...]
  • Verifier checks: at least 3 valid signatures from known board member public keys
  • Store the list of authorized public keys somewhere trusted

Design questions:

  • How do you handle a board member leaving? (Revoke their public key)
  • What if the document is modified after 2 of 3 sign? (Remaining signature fails — integrity)
  • Could you do threshold signatures (one combined 64-byte signature instead of 3 separate ones)?

Challenge 7: Multiplayer Game Anti-Cheat

Scenario: Two players play rock-paper-scissors over the network. Neither should be able to cheat by seeing the other’s move before committing their own.

Requirements:

  • Both commit to their move simultaneously
  • After both commit, both reveal
  • Cheating (changing your move after seeing the other’s) is detectable

Constraints:

  • No trusted server — peer-to-peer only
  • Network latency means messages aren’t truly simultaneous
Hints
  • Lesson 1 (Hashing): commit = hash(move + random_nonce)
  • Phase 1: both send their commitment (hash)
  • Phase 2: both reveal move + nonce
  • Phase 3: both verify opponent’s commitment matches
  • If anyone changed their move, the hash won’t match

Design questions:

  • What if player B sees player A’s commitment and tries every possible move (only 3 options) to find a match? (That’s why the nonce is essential — makes it impossible to reverse)
  • Can this scale to poker? (52 cards = more complex, needs mental poker protocol)
  • What if one player refuses to reveal? (They forfeit after a timeout)

Challenge 8: Whistleblower Drop

Scenario: A journalist runs a secure drop server. Sources can submit documents anonymously. The source needs to verify they’re talking to the real journalist (not law enforcement impersonating them), but the journalist must NOT know who the source is.

Requirements:

  • Source verifies the journalist’s identity (one-way authentication)
  • Journalist cannot identify the source
  • Messages can’t be replayed
  • Submitted documents are encrypted in transit

Constraints:

  • Source does NOT have a key pair (anonymous)
  • Only the journalist has identity keys
Hints
  • Lesson 10 (Authenticated Echo): one-way authentication — journalist signs, source verifies
  • Lesson 4 (Key Exchange): ephemeral DH for encryption (source generates throwaway keys)
  • Lesson 12 (Replay Defense): sequence numbers prevent replay
  • Source generates a fresh ephemeral key each session → no linkability between sessions

Design questions:

  • How is this similar to SecureDrop (used by NY Times, Washington Post)?
  • What metadata can still leak? (IP address, timing, file size)
  • How would Tor help here?

Challenge 9: Secure Software Update Pipeline

Scenario: You ship IoT thermostats. Each device checks for firmware updates over the internet. A compromised update could brick millions of devices or install malware.

Requirements:

  • Device verifies the update is from your company (not an attacker)
  • Update hasn’t been tampered with
  • Update is newer than the installed version (prevent rollback)
  • Update is encrypted in transit

Constraints:

  • Devices have limited CPU/memory
  • Devices are in customers’ homes (physical access possible)
  • The update server might be compromised
Hints
  • Lesson 3 (Signatures): sign the firmware with your company’s Ed25519 key
  • Lesson 1 (Hashing): include firmware hash in a signed metadata file
  • Lesson 7 (Certificates): embed the company’s public key in the device at factory
  • Version number in signed metadata prevents rollback
  • Lesson 14 (Real TLS): encrypt transit with TLS

Design questions:

  • What if your signing key is compromised? (Key rotation — embed next key in current update)
  • What if the update server is hacked? (Attacker can serve old signed updates — that’s why version checking matters)
  • What if someone captures and replays an old update to rollback a device?

Challenge 10: Exam Integrity System

Scenario: Students take an online exam. You want to prevent cheating by ensuring: (1) the professor can’t see answers before the deadline, (2) students can’t change answers after the deadline, (3) all answers are revealed simultaneously.

Requirements:

  • Students encrypt their answers before the deadline
  • Professor cannot decrypt until after the deadline
  • Students cannot modify their answers post-deadline
  • Professor can verify each student’s answer wasn’t changed

Constraints:

  • Students don’t trust the professor (might peek early)
  • Professor doesn’t trust the students (might change answers)
Hints
  • Phase 1 (before deadline): student encrypts answers with a random key, submits ciphertext + hash of key
  • Phase 2 (after deadline): student reveals the key
  • Professor decrypts with the key, verifies hash matches
  • Student can’t change answer (ciphertext already submitted)
  • Professor can’t peek (doesn’t have the key until reveal)

Design questions:

  • What if a student doesn’t reveal their key? (Marked as zero — same as not submitting)
  • What if the professor colludes with a student to leak the exam early? (Separate problem — this protocol only handles answer integrity)
  • How would you add anonymity? (Student submits through a mix network)

Advanced (after Phases 4-5)

Challenge 11: Encrypted DNS Resolver

Scenario: Your ISP logs every DNS query you make (they can see you visited bank.com even with HTTPS). Build a DNS client that sends queries over TLS to a trusted resolver.

Requirements:

  • DNS queries are encrypted in transit (your ISP can’t see them)
  • The resolver is authenticated (you verify its certificate)
  • Responses can’t be tampered with

Constraints:

  • Must follow the DNS-over-TLS (DoT) spec: TLS on port 853
  • The DNS wire format is binary (not HTTP)
Hints
  • Lesson 14 (tokio-rustls): wrap a TCP connection to 1.1.1.1:853 with TLS
  • DNS wire format: 2-byte length prefix + DNS message (similar to your tunnel framing!)
  • Send a DNS query for example.com, parse the response
  • Verify: compare results with dig example.com @1.1.1.1

Design questions:

  • What metadata still leaks? (The IP of the DNS resolver — your ISP sees you’re using 1.1.1.1)
  • How does DNS-over-HTTPS (DoH) differ? (Uses HTTP/2 on port 443, looks like normal web traffic)
  • Could you combine this with encrypted SNI (ECH) for full privacy?

Challenge 12: API Rate Limiter with Client Certificates

Scenario: You run a paid API. Free users get 100 requests/hour, Pro users get 10,000. You want to authenticate and rate-limit using mTLS — the client’s certificate contains their tier.

Requirements:

  • Clients authenticate with certificates signed by your CA
  • The certificate contains the tier (free/pro/enterprise) in a custom extension or the CN
  • Server reads the tier and enforces rate limits
  • No API keys, no tokens — just the cert

Constraints:

  • Client certs are issued by your CA (Lesson 8)
  • Tier cannot be changed by the client (it’s signed by the CA)
Hints
  • P8 (CA): issue certs with CN like free:alice or pro:bob
  • Lesson 11 (mTLS): require client cert on the server
  • After handshake, extract CN from client cert → parse tier
  • Rate limit based on tier

Design questions:

  • What if a free user shares their cert+key with others? (Track cert serial number, revoke if abused)
  • How do you upgrade a user from free to pro? (Issue new cert, revoke old one)
  • How does this compare to JWT tokens? (Certs are verified at TLS level — no application code needed)

Scenario: Alice uploads a file. She gets a link she can share with anyone. The link works for 24 hours. The server stores the file but CANNOT read its contents.

Requirements:

  • File is encrypted client-side before upload
  • The decryption key is in the link fragment (never sent to server)
  • Link expires after 24 hours
  • Server stores ciphertext only

Constraints:

  • Server must not have the decryption key
  • Anyone with the link can decrypt (no pre-shared keys)
Hints
  • Lesson 2 (Encryption): encrypt file with a random key client-side
  • Upload ciphertext to server, get a file ID
  • Link format: https://share.example.com/files/abc123#key=hexencodedkey
  • The #fragment is never sent to the server (browser rule)
  • Server handles expiry (delete after 24h)
  • Recipient’s browser downloads ciphertext, extracts key from fragment, decrypts

Design questions:

  • This is how Firefox Send worked (before Mozilla shut it down). Why did they shut it down?
  • What if the server is malicious and serves modified JavaScript? (Could steal the key)
  • How would you add password protection on top? (PBKDF2 on the password → wrap the file key)

Challenge 14: Forward-Secret Chat (Double Ratchet)

Scenario: Alice and Bob chat. Each message should use a unique key. If message #47’s key leaks, messages #1-46 and #48+ remain secure. This is how Signal works.

Requirements:

  • Each message is encrypted with a different key
  • Compromising one message key reveals nothing about others
  • Keys automatically “ratchet” forward after each message

Constraints:

  • No server holds any keys
  • Must work asynchronously (Alice sends 5 messages before Bob reads any)
Hints
  • Lesson 4 (DH): periodically do new DH exchanges to ratchet the root key
  • Lesson 5 (HKDF): derive message keys from a chain: chain_key_n+1 = HKDF(chain_key_n, "chain")
  • Each message key is derived then discarded
  • New DH exchange → new root key → new chain → all old keys are unrecoverable

Design questions:

  • How many DH ratchet steps per message? (Signal: every time the sender changes, not every message)
  • What about message ordering? (Signal uses message counters within each chain)
  • This is the Signal Protocol — used by Signal, WhatsApp, Facebook Messenger. Read the spec.

Challenge 15: Cryptocurrency Wallet

Scenario: Build a simple wallet: generate key pairs, create “transactions” (Alice sends 10 coins to Bob), sign them, verify them.

Requirements:

  • Key generation: Ed25519 key pair per user
  • Transaction: {from: pubkey, to: pubkey, amount: u64, signature: sig}
  • Signature covers: from + to + amount (prevents tampering)
  • Wallet file encrypted with password (Lesson 6)

Constraints:

  • No actual blockchain — just the signing/verification layer
  • Focus on the cryptographic operations, not consensus
Hints
  • Lesson 3 (Signatures): sign(sender_private, hash(from || to || amount))
  • Lesson 6 (Password KDF): encrypt the wallet (private keys) with Argon2 + ChaCha20
  • Lesson 1 (Hashing): transaction ID = hash of the transaction

Design questions:

  • What prevents double-spending? (In real crypto: a blockchain ledger. Here: just the signature layer)
  • What if someone steals the wallet file? (Password-encrypted, but attacker can brute-force offline)
  • How does this compare to Bitcoin (secp256k1 + SHA-256) or Ethereum (secp256k1 + Keccak)?