bench/src/trust.rs
//! **`trust`** — gunnar's PKI: one CA, one server certificate, N client
//! certificates.
//!
//! # What this is for
//!
//! `gunnar-server` authenticates a client today by SSH public key, reduced to a
//! SHA256 `Fingerprint`. mTLS is the second way to arrive at the same
//! fingerprint, and every grant check downstream is unchanged.
//!
//! This module is the **material** side of that: it mints the certificates a
//! test or a bench needs, from one CA named `trust`. It is deliberately not the
//! verification side — that is the authentication boundary, and a boundary wants
//! its red proof written before its green one.
//!
//! # Why the CA is generated rather than shipped
//!
//! A checked-in key is a key that leaks, and a checked-in certificate is one
//! that expires on a date nobody is watching. Every run mints its own CA into a
//! directory it was handed, so a run cannot pass because it trusted something a
//! previous one left behind — the same reason the fixtures build their own
//! repositories rather than reusing whatever is on disk.
//!
//! # Why key usage is set explicitly
//!
//! `certsign`/`crlsign` on the CA, `serverauth` on the server, `clientauth` on
//! the clients. Not decoration: a peer that checks refuses a certificate lacking
//! the usage for the role it is presented in, and a certificate carrying every
//! usage is one that can be used for anything it is stolen for. Setting them
//! narrowly is also what makes a *negative* test possible — a client certificate
//! offered as a server certificate must fail, and it cannot fail if everything
//! is permitted.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use cert_helper::certificate::{
CertBuilder, Certificate, KeyType, Usage, UseesBuilderFields, X509Common,
};
/// The name gunnar's CA goes by.
pub const CA_NAME: &str = "trust";
/// One issued identity and where its material landed.
///
/// `cert-helper` writes `<name>_cert.pem` and `<name>_pkey.pem`, so both paths
/// are recorded rather than recomputed by a caller that would have to know that
/// convention.
#[derive(Debug, Clone)]
pub struct Identity {
pub name: String,
pub cert: PathBuf,
pub key: PathBuf,
}
/// A CA and everything issued under it.
#[derive(Debug, Clone)]
pub struct Trust {
/// The trust root. A server verifying clients is configured with this and
/// nothing else — which is what makes it a root rather than a convenience.
pub ca: Identity,
/// What `gunnar-server` presents.
pub server: Identity,
/// What a `gunnar-cli` presents. One per client.
pub clients: Vec<Identity>,
}
fn issued(dir: &Path, name: &str) -> Identity {
Identity {
name: name.to_owned(),
cert: dir.join(format!("{name}_cert.pem")),
key: dir.join(format!("{name}_pkey.pem")),
}
}
/// Mint the CA `trust`, one server certificate and `clients` client
/// certificates, all under `dir`.
///
/// `server_names` are the subject alternative names the server certificate
/// carries. A certificate whose SAN does not cover the address a client dialled
/// is refused by every correct TLS stack, and that presents as "the handshake
/// failed" a long way from its cause — so it is a parameter rather than a guess.
pub fn mint(dir: &Path, server_names: &[&str], clients: usize) -> Result<Trust> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the trust directory {}", dir.display()))?;
let dir_str = dir.to_str().context("the trust directory is not utf-8")?;
let ca = CertBuilder::new()
.common_name(CA_NAME)
.organization("gunnar")
.is_ca(true)
.key_type(KeyType::Ed25519)
.key_usage([Usage::certsign, Usage::crlsign].into_iter().collect())
.build_and_self_sign()
.map_err(|e| anyhow::anyhow!("self-signing the {CA_NAME} CA: {e}"))?;
ca.save(dir_str, CA_NAME)
.map_err(|e| anyhow::anyhow!("writing the CA: {e}"))?;
let ca_id = issued(dir, CA_NAME);
// Reloaded from disk rather than reused from memory: that is the CA a server
// will actually be handed, so signing with anything else would prove less.
let ca_cert = Certificate::load_cert_and_key(&ca_id.cert, &ca_id.key)
.map_err(|e| anyhow::anyhow!("reloading the CA: {e}"))?;
let server = CertBuilder::new()
.common_name(server_names.first().copied().unwrap_or("localhost"))
.organization("gunnar")
.alternative_names(server_names.to_vec())
.key_type(KeyType::Ed25519)
.key_usage([Usage::serverauth].into_iter().collect())
.build_and_sign(&ca_cert)
.map_err(|e| anyhow::anyhow!("signing the server certificate: {e}"))?;
server
.save(dir_str, "server")
.map_err(|e| anyhow::anyhow!("writing the server certificate: {e}"))?;
let mut out = Vec::with_capacity(clients);
for i in 1..=clients {
let name = format!("client{i}");
let cert = CertBuilder::new()
.common_name(&name)
.organization("gunnar")
.key_type(KeyType::Ed25519)
.key_usage([Usage::clientauth].into_iter().collect())
.build_and_sign(&ca_cert)
.map_err(|e| anyhow::anyhow!("signing {name}: {e}"))?;
cert.save(dir_str, &name)
.map_err(|e| anyhow::anyhow!("writing {name}: {e}"))?;
out.push(issued(dir, &name));
}
Ok(Trust {
ca: ca_id,
server: issued(dir, "server"),
clients: out,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// **Every file exists, and every client certificate verifies against the
/// CA — checked by `openssl`, not by the crate that minted them.**
///
/// A library confirming its own output is the circularity that lets a wrong
/// chain through: it would agree with itself even if both halves were wrong
/// the same way. `openssl verify` shares no code with `cert-helper`.
#[test]
fn openssl_verifies_every_certificate_against_the_ca() {
if std::process::Command::new("openssl")
.arg("version")
.output()
.is_err()
{
eprintln!("skipping: no openssl on PATH");
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let trust = mint(dir.path(), &["localhost", "127.0.0.1"], 5).expect("minting");
assert_eq!(trust.clients.len(), 5, "five clients were asked for");
for id in std::iter::once(&trust.ca)
.chain(std::iter::once(&trust.server))
.chain(trust.clients.iter())
{
assert!(id.cert.exists(), "{} has no certificate", id.name);
assert!(id.key.exists(), "{} has no key", id.name);
}
for id in std::iter::once(&trust.server).chain(trust.clients.iter()) {
let out = std::process::Command::new("openssl")
.arg("verify")
.arg("-CAfile")
.arg(&trust.ca.cert)
.arg(&id.cert)
.output()
.expect("running openssl verify");
assert!(
out.status.success(),
"openssl will not verify {} against the {CA_NAME} CA:\n{}",
id.name,
String::from_utf8_lossy(&out.stderr)
);
}
}
/// The CA must be the **only** thing that verifies them.
///
/// Without this, the test above passes against a chain that any CA would
/// accept — which is not a trust root, it is a decoration. A second,
/// unrelated CA must reject what the first one signed.
#[test]
fn a_different_ca_refuses_what_trust_signed() {
if std::process::Command::new("openssl")
.arg("version")
.output()
.is_err()
{
eprintln!("skipping: no openssl on PATH");
return;
}
let ours = tempfile::tempdir().expect("tempdir");
let theirs = tempfile::tempdir().expect("tempdir");
let mine = mint(ours.path(), &["localhost"], 1).expect("minting ours");
let other = mint(theirs.path(), &["localhost"], 1).expect("minting theirs");
let out = std::process::Command::new("openssl")
.arg("verify")
.arg("-CAfile")
.arg(&other.ca.cert)
.arg(&mine.clients[0].cert)
.output()
.expect("running openssl verify");
assert!(
!out.status.success(),
"an unrelated CA verified a certificate it never signed; the trust root is not a \
root at all"
);
}
}