Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 p7-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 p7-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 p7-ca -- issue --domain db.internal.com --days 90

# List all issued certs:
cargo run -p tls --bin p7-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 p7-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/p7-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 p7-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 p7-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 p7-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 p7-ca -- revoke --domain api.internal.com
# Revoked: api.internal.com

cargo run -p tls --bin p7-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.

Extension: a CA is the backbone of an mTLS service mesh

Your CA doesn’t just issue server certs. Issue one per service and you’ve built the core of what Istio, Linkerd, and every corporate zero-trust network do: services prove their identity to each other using certificates from a shared internal CA.

┌──────────────────────────────────────────────────────────┐
│  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 → handshake fails.  │
│    Only order-service (with its cert) can call it.       │
└──────────────────────────────────────────────────────────┘

Two services, both signed by your CA, mutually authenticating:

┌──────────────────┐          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: Your CA (this project)

Requiring client certificates on the server

One line 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)?;
}

Presenting a client certificate

#![allow(unused)]
fn main() {
let config = ClientConfig::builder()
    .with_root_certificates(ca_root_store)
    .with_client_auth_cert(client_certs, client_key)?;
}

From authentication to authorization

The TLS handshake tells you who. Your application decides what they can do. After the handshake, read the peer’s cert and enforce a policy:

#![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();
let peer_name = extract_cn(&cert);   // e.g. "order.internal"

match (method, path) {
    ("POST", "/charge") if peer_name == "order.internal" => { /* allowed */ }
    ("POST", "/charge")                                  => respond_403("forbidden"),
    _ => respond_404(),
}
}

Exercise 5: Two-service mTLS

Issue order.internal and payment.internal certs from your CA. Run the payment service with client-cert verification on. Run the order service as a client presenting its cert. Prove it works (connection succeeds) and that an unauthorized client (no cert) is rejected during the handshake — before your app code even runs.

Exercise 6: Authorization matrix

Add a third service (inventory.internal). Define the matrix in a file:

# mesh-policy.toml
[payment]
accepts = ["order"]

[inventory]
accepts = ["order"]

[order]
accepts = []   # entry point — nobody calls it directly

Load it at startup. For each incoming connection, extract peer CN, check the matrix, 403 if not allowed. This is roughly how Istio AuthorizationPolicy works.

Exercise 7: Rotate the CA

The hardest operational problem in a real mesh. Generate a new CA. Issue a new cert for one service from the new CA while others still trust only the old CA. Observe the break. Fix it by having every service trust both CAs during a rollover window, then drop the old CA.