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:
- Integrity — the message wasn’t modified
- 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:
| Name | Role |
|---|---|
| Alice | Initiator (usually the client) |
| Bob | Responder (usually the server) |
| Eve | Eavesdropper — passively listens to traffic |
| Mallory | Active attacker — can modify, inject, and replay messages |
| Trent | Trusted 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:
- Ask for a secret prediction
- Hash it, print the hash (commitment)
- Ask the user to reveal the prediction
- Hash the revealed text, compare
- 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.
- Alice generates an Ed25519 key pair. Publishes her public key on her website.
- Alice builds version 2.0, signs the binary:
sign(alice_private, binary) → sig - Alice uploads
binaryandsigto the download mirror - Bob downloads both. He has Alice’s public key from her website.
- Bob runs
verify(alice_public, binary, sig)→ success - An attacker modifies the binary on the mirror. Bob downloads it.
- 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:
- Server has a long-term Ed25519 key pair (in
/etc/ssh/ssh_host_ed25519_key) - During SSH handshake, server signs session data with its private key
- Client verifies the signature against the server’s known public key (in
~/.ssh/known_hosts) - 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:
- Server sends its ephemeral DH public key (for key exchange, Lesson 4)
- Server signs the handshake transcript (all messages so far) with its long-term private key
- Client verifies the signature using the server’s public key from the certificate (Lesson 6)
- If the signature is valid → the client knows the DH public key genuinely came from the server
- 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:
- Create a “binary” (any byte array)
- Sign it, save the signature to a separate “file” (
Vec<u8>) - 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)
- Alice and Bob publicly agree on a base color: yellow
- Alice picks a secret color: red. Mixes red + yellow → orange. Sends orange to Bob.
- Bob picks a secret color: blue. Mixes blue + yellow → green. Sends green to Alice.
- Alice mixes Bob’s green + her secret red → brown
- 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.
- Alice generates an ephemeral X25519 key pair:
(alice_secret, alice_public) - Bob generates an ephemeral X25519 key pair:
(bob_secret, bob_public) - Alice sends
alice_public(32 bytes) to Bob over the internet - Bob sends
bob_public(32 bytes) to Alice over the internet - Alice computes:
shared = alice_secret.dh(bob_public)→ 32-byte secret - Bob computes:
shared = bob_secret.dh(alice_public)→ same 32-byte secret - Both use this shared secret as an encryption key (or derive keys via HKDF, Lesson 5)
- 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:
- Monday: Client and server do DH →
shared_1. Encrypt traffic. Destroy ephemeral keys. - Tuesday: Client and server do DH →
shared_2. Encrypt traffic. Destroy ephemeral keys. - 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:
- Alice generates keys, sends
alice_publicto Mallory (thinking it’s Bob) - Mallory generates her own keys, sends
mallory_publicto Alice (pretending to be Bob) - Mallory sends
mallory_public2to Bob (pretending to be Alice) - Bob sends
bob_publicto Mallory - Mallory now has two different shared secrets: one with Alice, one with Bob
- 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:
- Is the base32 decoding correct? (Step 1)
- Is the time step the same? (Step 2 — clocks might differ by a second across the boundary)
- 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:
- Tampered file → invalid
- Wrong public key → invalid
- 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(×tamp.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(×tamp.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(×tamp.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:
- Integrity: the message wasn’t modified
- 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.
- Both compute:
PRK = HKDF-Extract(salt="tls13", shared_secret) - Alice derives:
c2s_key = HKDF-Expand(PRK, "client-to-server", 32) - Alice derives:
s2c_key = HKDF-Expand(PRK, "server-to-client", 32) - Bob derives the exact same two keys (same PRK, same labels)
- Alice encrypts messages TO Bob with
c2s_key - Alice decrypts messages FROM Bob with
s2c_key - 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) — fastt_cost = 3, m_cost = 65536(64MB) — defaultt_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:
- Ask for a password
- Generate a random salt
- Derive a key with Argon2
- Encrypt a file with ChaCha20-Poly1305 using that key
- Store
salt || nonce || ciphertextto disk - 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 withkey_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:
- Read the server certificate → signed by Intermediate CA
- Read the Intermediate CA certificate → signed by Root CA
- Root CA is in the trusted store → chain is valid
- 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
- Alice navigates to
https://bank.com - Bank’s server sends its certificate: “I am bank.com, here’s my public key, signed by DigiCert”
- 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.comYes
- Browser proceeds with TLS handshake using the server’s public key
- 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.
- Bob generates a self-signed CA certificate (his own private root)
- Bob generates a server certificate for
api.internal.corp, signs it with his CA - Bob installs his CA certificate on all client machines
- Clients trust
api.internal.corpbecause 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:
- You prove you control a domain (by placing a file on your web server or adding a DNS record)
- Let’s Encrypt issues a free certificate, valid for 90 days
- 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
- Generate a CA certificate (with
is_ca = IsCa::Ca(...)) - Generate a server certificate for “localhost”
- Sign the server cert with the CA
- Verify:
openssl verify -CAfile ca.pem server.pem
Exercise 3: Use with tokio-rustls
Take the CA + server cert from Exercise 2:
- Server: load server cert + key into
rustls::ServerConfig - Client: load CA cert into
rustls::ClientConfigroot store - Connect — the handshake should succeed
- 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:
- Key exchange (Lesson 4): X25519 Diffie-Hellman
- Key derivation (Lesson 5): HKDF to produce two independent keys
- 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
- Alice generates an ephemeral X25519 key pair
- Alice sends her 32-byte public key to Bob
- Bob generates an ephemeral X25519 key pair
- Bob sends his 32-byte public key to Alice
- Both compute:
shared_secret = DH(my_secret, their_public) - Both derive:
c2s_key = HKDF(shared, "c2s")ands2c_key = HKDF(shared, "s2c")
Eve sees both public keys on the wire. She cannot compute the shared secret (Lesson 4).
Encrypted communication
- Alice types “meet at 3pm”
- Alice generates a random 12-byte nonce
- Alice encrypts:
ciphertext = ChaCha20Poly1305(c2s_key, nonce, "meet at 3pm") - Alice sends:
[length][nonce][ciphertext] - Bob reads the length, reads that many bytes, splits nonce and ciphertext
- Bob decrypts:
plaintext = ChaCha20Poly1305(c2s_key, nonce, ciphertext)→ “meet at 3pm” - 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
| Feature | Our implementation | TLS 1.3 |
|---|---|---|
| Key exchange | X25519 | X25519 or P-256 |
| Key derivation | HKDF-SHA256 | HKDF-SHA256 or SHA384 |
| Encryption | ChaCha20-Poly1305 | ChaCha20-Poly1305 or AES-GCM |
| Authentication | None | Certificates + signatures |
| Nonce | Random per message | Counter (sequence number) |
| Handshake | 1-RTT (2 messages) | 1-RTT (2 flights) |
| Session resumption | No | 0-RTT with PSK |
| Record framing | 2-byte length | 2-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
- Mallory intercepts Alice’s DH public key
- Mallory generates her own DH key pair, sends
mallory_dh_pubto Alice - Mallory needs to send a valid signature:
sign(server_identity_private, mallory_dh_pub) - Mallory doesn’t have
server_identity_private— she can’t forge the signature - 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
| Feature | Lesson 9 | Lesson 10 | TLS 1.3 |
|---|---|---|---|
| Key exchange | X25519 | X25519 | X25519 or P-256 |
| Server auth | None | Ed25519 signature | RSA/ECDSA/Ed25519 via certificate |
| Trust model | None | Pinned public key | CA hierarchy |
| Client auth | None | None | Optional (mutual TLS) |
| What’s signed | Nothing | DH public key | Full 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?
- A certificate authority (often built into the service mesh like Istio or Linkerd) issues certificates to each service
- When Service A calls Service B, both present their certificates
- Service A verifies Service B’s cert → “I’m talking to the real database service”
- Service B verifies Service A’s cert → “This request is from the authorized API service, not an attacker”
- 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.
- Employee’s laptop has a client certificate (often stored in a hardware TPM)
- Corporate services require mTLS — they verify the client certificate
- Even if an attacker gets on the corporate network, they can’t access services without a valid client certificate
- 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:
- A fintech company registers with a bank and receives a client certificate
- Every API call to the bank uses mTLS
- The bank verifies the fintech’s certificate → “This is an authorized payment provider”
- The fintech verifies the bank’s certificate → “This is the real bank, not a phishing server”
- 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:
- Receiver saves how many bytes/chunks were received
- On reconnect, receiver tells sender “I have chunks 0-47”
- 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:
| rustls | OpenSSL (via openssl crate) | |
|---|---|---|
| Language | Pure Rust | C (with Rust bindings) |
| Safety | Memory-safe by default | Long history of CVEs (Heartbleed, etc.) |
| Performance | Competitive, sometimes faster | Hardware-accelerated AES |
| Ciphers | Modern only (TLS 1.2+) | Everything including legacy |
| Dependencies | No C toolchain needed | Requires 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:
- A CA certificate (self-signed root)
- A server certificate signed by that CA
- 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:
- Generate certificates
- Server: wrap
TcpListenerwithTlsAcceptor - Client: wrap
TcpStreamwithTlsConnector - 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:
- Your client and the server exchanged X25519 keys (Lesson 4)
- The server’s certificate was verified against a CA (Lesson 6)
- The server signed the handshake (Lesson 3/8)
- Session keys were derived with HKDF (Lesson 5)
- All data is encrypted with AES-GCM or ChaCha20-Poly1305 (Lesson 2)
- Each record has a sequence number nonce (Lesson 12)
- 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:
- DNS lookup → IP address
- TCP connect to port 443
- TLS handshake (what you built in Lessons 4-8, plus certificate chain validation from Lesson 6)
- Send HTTP request through the encrypted tunnel
- Receive HTTP response
- 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:
- Connects TCP to example.com:443
- Does a TLS handshake (using OpenSSL or rustls depending on build)
- Sends
GET / HTTP/1.1\r\nHost: example.com\r\n\r\n - 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.htmlGET /style.css→ read./public/style.css- Set
Content-Typebased 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
- Read the scenario — understand what the system needs to do
- List the requirements — what properties must hold (confidentiality, integrity, authentication?)
- Pick your primitives — which lessons apply? (hashing, encryption, signatures, key exchange, certificates?)
- Draw the protocol — what messages are exchanged? What does each party store?
- Implement — build a working prototype in Rust
- 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:853with 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:aliceorpro: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)
Challenge 13: Secure File Sharing with Expiring Links
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
#fragmentis 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)?