Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
        );
    }
}