← Week 3: TLS Integration and CLI

Day 18: Persistence — PEM Store and Cert Index

Phase 2 · July 4, 2026

← Week 3: TLS Integration and CLI

Agenda (2–3 hours)

  • Read (20 min): serde_json docs; std::fs path patterns in Rust
  • Build (120 min): Implement store.rs — PEM files + JSON index
  • Verify (30 min): Round-trip test: issue → save → load → validate

Note: July 4th. Keep the session lighter if needed — this day's content is important
but not the most cognitively demanding. Good day for solid, focused engineering work.

← Week 3: TLS Integration and CLI

What Needs to Persist

toy-pki/
├── ca/
│   ├── root.crt              ← root CA cert (PEM)
│   ├── root.key              ← root CA key (PEM, protect this)
│   ├── intermediate.crt      ← intermediate cert (PEM)
│   ├── intermediate.key      ← intermediate key
│   └── intermediate.crl      ← latest signed CRL (DER)
├── certs/
│   ├── 0001.crt              ← issued cert (PEM), named by serial
│   ├── 0001.key              ← issued key (PEM)
│   └── ...
├── index.json                ← CertRecord list (serial, subject, status, expiry)
└── crl.json                  ← CrlStore (revocation list + crl_number)
← Week 3: TLS Integration and CLI

CertRecord and the Index

#[derive(Serialize, Deserialize)]
pub struct CertRecord {
    pub serial:      String,
    pub subject:     String,
    pub cert_type:   CertType,   // Server | Client | Intermediate
    pub not_before:  String,     // RFC 3339
    pub not_after:   String,
    pub pem_path:    PathBuf,    // relative path to .crt file
    pub revoked_at:  Option<String>,
    pub revoke_reason: Option<String>,
}

#[derive(Serialize, Deserialize)]
pub struct CertStore {
    pub records: Vec<CertRecord>,
}

impl CertStore {
    pub fn load(path: &Path) -> anyhow::Result<Self>;
    pub fn save(&self, path: &Path) -> anyhow::Result<()>;
    pub fn find_by_serial(&self, serial: &str) -> Option<&CertRecord>;
    pub fn add(&mut self, record: CertRecord);
}
← Week 3: TLS Integration and CLI

Serial Number Generation

Generate random 20-byte serials (RFC 5280 §4.1.2.2 recommends ≥ 20 bytes of entropy):

use rand::RngCore;

pub fn generate_serial() -> String {
    let mut bytes = [0u8; 20];
    rand::thread_rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}

Use this serial as both the file name (<serial>.crt) and the key in index.json.

← Week 3: TLS Integration and CLI

Atomic Writes

When updating index.json or crl.json, use atomic write to avoid corruption:

pub fn save_atomic(path: &Path, content: &str) -> anyhow::Result<()> {
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, content)?;
    std::fs::rename(&tmp, path)?;  // atomic on most filesystems
    Ok(())
}

This prevents a half-written file from corrupting your cert store on crash.

← Week 3: TLS Integration and CLI

Challenge Assignment

Implement store.rs with CertStore and CertRecord.

Update issue_server_cert() and issue_client_cert() to write to the store automatically:

  • Write <serial>.crt and <serial>.key to certs/
  • Add CertRecord to index.json

Write a test test_store_round_trip:

  1. Issue 3 certs — all saved to disk
  2. Load CertStore from disk in a fresh process (use std::process::Command on a test binary)
  3. Verify all 3 records present with correct fields
  4. Revoke one — verify revoked_at is populated in the reloaded store
← Week 3: TLS Integration and CLI

Resources

  • serde + serde_json for CertRecord / CertStore
  • rand crate for serial generation
  • std::fs::rename for atomic writes (POSIX guarantee)
  • tempfile::TempDir for isolated test directories