polyc-crypto 2026.8.3

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
//! Shared CA-bundle PEM loading for TLS trust stores.
//!
//! Every trust-store consumer (the #1167
//! control-plane<->harness mTLS pair) needs the same fail-closed
//! "read this PEM file, add every certificate in it to a root store, error
//! loudly if the file is unreadable or carries none" shape. One shared
//! loader means that failure-loudness behavior can't drift between call
//! sites.

use std::path::Path;
use std::sync::Arc;

use rustls::RootCertStore;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};

/// A CA bundle failed to load into a [`RootCertStore`].
#[derive(Debug, thiserror::Error)]
pub enum CaLoadError {
    /// The file couldn't be opened or read.
    #[error("read CA file {path}: {source}")]
    Read {
        /// The path that failed to read.
        path: String,
        /// The underlying I/O/PEM-decode error.
        #[source]
        source: rustls::pki_types::pem::Error,
    },
    /// A certificate in the file was structurally invalid.
    #[error("parse CA certificate in {path}: {source}")]
    Parse {
        /// The file the invalid certificate came from.
        path: String,
        /// The underlying PEM-decode error.
        #[source]
        source: rustls::pki_types::pem::Error,
    },
    /// A certificate was well-formed but rustls rejected it (e.g. an
    /// unsupported signature algorithm).
    #[error("add CA certificate from {path} to root store: {source}")]
    Reject {
        /// The file the rejected certificate came from.
        path: String,
        /// The underlying rustls error.
        #[source]
        source: rustls::Error,
    },
    /// The file parsed but named zero certificates — an explicitly
    /// configured CA that's empty or malformed in a way that doesn't error.
    #[error("CA file {path} contained no certificates")]
    Empty {
        /// The empty file's path.
        path: String,
    },
}

/// Load every certificate in the PEM file `path` into `roots`.
///
/// Fail-closed: refusing to silently produce a verifier that trusts nothing
/// (which would reject every real connection with no signal *why*) is the
/// caller's job — this function only reports the file-level problem
/// (unreadable, unparseable, empty) so the caller can refuse to start rather
/// than guess.
///
/// # Errors
///
/// Returns [`CaLoadError`] if the file can't be read, contains an
/// unparseable or rustls-rejected certificate, or names no certificates at
/// all.
pub fn load_ca_into(roots: &mut RootCertStore, path: &str) -> Result<(), CaLoadError> {
    let mut added = 0usize;
    for cert in CertificateDer::pem_file_iter(path).map_err(|source| CaLoadError::Read {
        path: path.to_owned(),
        source,
    })? {
        let cert = cert.map_err(|source| CaLoadError::Parse {
            path: path.to_owned(),
            source,
        })?;
        roots.add(cert).map_err(|source| CaLoadError::Reject {
            path: path.to_owned(),
            source,
        })?;
        added += 1;
    }
    if added == 0 {
        return Err(CaLoadError::Empty {
            path: path.to_owned(),
        });
    }
    Ok(())
}

/// Load every certificate in the in-memory PEM bundle `pem` into `roots`.
///
/// Same fail-closed contract and error shape as [`load_ca_into`], for a CA
/// bundle already held in memory (e.g. read from a Kubernetes Secret) rather
/// than a file on disk. `label` identifies the source in error messages
/// (there's no path to report).
///
/// # Errors
///
/// Returns [`CaLoadError`] if `pem` contains an unparseable or
/// rustls-rejected certificate, or names no certificates at all.
pub fn load_ca_pem_into(
    roots: &mut RootCertStore,
    label: &str,
    pem: &[u8],
) -> Result<(), CaLoadError> {
    let mut added = 0usize;
    for cert in CertificateDer::pem_slice_iter(pem) {
        let cert = cert.map_err(|source| CaLoadError::Parse {
            path: label.to_owned(),
            source,
        })?;
        roots.add(cert).map_err(|source| CaLoadError::Reject {
            path: label.to_owned(),
            source,
        })?;
        added += 1;
    }
    if added == 0 {
        return Err(CaLoadError::Empty {
            path: label.to_owned(),
        });
    }
    Ok(())
}

/// Which piece of a mutual-TLS client identity one load failure names.
///
/// An enum rather than a string, so the set a reader is promised and the set
/// [`mutual_tls_client_config`] emits cannot drift apart: adding a piece here
/// is a compile error at every match over it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityPart {
    /// The authority bundle the listener's own leaf is verified against.
    AuthorityCertificate,
    /// The caller's own certificate chain.
    ClientCertificate,
    /// The private key that chain is presented with.
    ClientKey,
    /// The assembled pair, which rustls refused as a unit rather than naming
    /// either half.
    CertificateAndKey,
}

impl std::fmt::Display for IdentityPart {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::AuthorityCertificate => "authority certificate",
            Self::ClientCertificate => "client certificate",
            Self::ClientKey => "client key",
            Self::CertificateAndKey => "certificate and key",
        })
    }
}

/// A mutual-TLS client identity failed to load.
///
/// `subject` names whose identity it is, in the caller's own words, so one
/// shared loader can serve several callers without any of them reporting a
/// failure as someone else's, and `part` names which piece of that identity
/// went wrong. The cause is boxed to keep the error small: `rustls::Error`
/// alone is wide enough that carrying [`ClientIdentityCause`] inline trips
/// `clippy::result_large_err` on every `Result` in this module.
#[derive(Debug, thiserror::Error)]
#[error("the {part} for the {subject} at {path} did not load")]
pub struct ClientIdentityError {
    /// Whose identity was being loaded.
    subject: String,
    /// Which piece of that identity went wrong.
    part: IdentityPart,
    /// The path the piece was read from.
    path: String,
    /// What actually went wrong.
    #[source]
    source: Box<ClientIdentityCause>,
}

impl ClientIdentityError {
    /// Returns which piece of the identity failed.
    #[must_use]
    pub const fn part(&self) -> IdentityPart {
        self.part
    }

    /// Returns what went wrong with that piece.
    #[must_use]
    pub const fn cause(&self) -> &ClientIdentityCause {
        &self.source
    }
}

/// What went wrong with one piece of a mutual-TLS client identity.
#[derive(Debug, thiserror::Error)]
pub enum ClientIdentityCause {
    /// The authority bundle did not yield a usable trust store.
    #[error(transparent)]
    Authority(#[from] CaLoadError),
    /// The file could not be opened or read.
    #[error(transparent)]
    Read(#[from] std::io::Error),
    /// The PEM contents did not decode.
    #[error("{0}")]
    Parse(String),
    /// rustls refused the assembled certificate and key.
    #[error(transparent)]
    Build(#[from] rustls::Error),
}

/// Builds the mutual-TLS client configuration `subject` presents to a listener
/// that admits callers by client certificate.
///
/// One loader, because a second hand-rolled copy is how two callers of the same
/// authenticated listener come to disagree about what they present: `ca` is the
/// authority the server's leaf is verified against, and `cert`/`key` are the
/// caller's own leaf, whose digest is the workload identity the listener admits
/// it under. Hostname verification stays the ordinary one — the server's leaf
/// carries the name it is dialed by.
///
/// Fail-closed throughout: an unreadable, unparseable, or empty piece is an
/// error rather than a configuration that trusts nothing and refuses every real
/// connection with no signal why.
///
/// # Errors
///
/// Returns [`ClientIdentityError`] when the authority bundle does not load,
/// when either the certificate chain or the private key cannot be read or
/// parsed, or when rustls refuses the assembled pair.
pub fn mutual_tls_client_config(
    subject: &str,
    ca: &Path,
    cert: &Path,
    key: &Path,
) -> Result<Arc<rustls::ClientConfig>, ClientIdentityError> {
    let fail = |part: IdentityPart, path: &Path, cause: ClientIdentityCause| ClientIdentityError {
        subject: subject.to_owned(),
        part,
        path: path.display().to_string(),
        source: Box::new(cause),
    };
    let read = |path: &Path, part: IdentityPart| -> Result<Vec<u8>, ClientIdentityError> {
        std::fs::read(path).map_err(|err| fail(part, path, err.into()))
    };

    let ca_pem = read(ca, IdentityPart::AuthorityCertificate)?;
    let mut roots = RootCertStore::empty();
    load_ca_pem_into(&mut roots, subject, &ca_pem)
        .map_err(|err| fail(IdentityPart::AuthorityCertificate, ca, err.into()))?;

    let cert_pem = read(cert, IdentityPart::ClientCertificate)?;
    let chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
        .collect::<Result<_, _>>()
        .map_err(|err| {
            fail(
                IdentityPart::ClientCertificate,
                cert,
                ClientIdentityCause::Parse(err.to_string()),
            )
        })?;

    let key_pem = read(key, IdentityPart::ClientKey)?;
    let private = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|err| {
        fail(
            IdentityPart::ClientKey,
            key,
            ClientIdentityCause::Parse(err.to_string()),
        )
    })?;

    rustls::ClientConfig::builder()
        .with_root_certificates(roots)
        .with_client_auth_cert(chain, private)
        .map(Arc::new)
        .map_err(|err| fail(IdentityPart::CertificateAndKey, cert, err.into()))
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;

    #[test]
    fn missing_file_is_a_read_error() {
        let mut roots = RootCertStore::empty();
        let err = load_ca_into(&mut roots, "/nonexistent/ca.pem").unwrap_err();
        assert!(matches!(err, CaLoadError::Read { .. }), "got {err:?}");
        assert!(err.to_string().contains("ca.pem"), "got: {err}");
    }

    #[test]
    fn empty_file_is_an_empty_error() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("polyc-crypto-tls-test-{}.pem", std::process::id()));
        std::fs::write(&path, b"").unwrap();
        let mut roots = RootCertStore::empty();
        let err = load_ca_into(&mut roots, path.to_str().unwrap()).unwrap_err();
        std::fs::remove_file(&path).ok();
        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
    }

    #[test]
    fn empty_pem_bytes_is_an_empty_error() {
        let mut roots = RootCertStore::empty();
        let err = load_ca_pem_into(&mut roots, "test CA", b"").unwrap_err();
        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
        assert!(err.to_string().contains("test CA"), "got: {err}");
    }

    #[test]
    fn a_missing_client_identity_names_its_subject_and_the_piece_that_is_gone() {
        let missing = Path::new("/nonexistent/polychrome/ca.crt");
        let err = mutual_tls_client_config("State client", missing, missing, missing).unwrap_err();
        assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
        assert!(
            matches!(err.cause(), ClientIdentityCause::Read(_)),
            "got {:?}",
            err.cause()
        );
        let rendered = err.to_string();
        assert_eq!(
            rendered,
            "the authority certificate for the State client at /nonexistent/polychrome/ca.crt \
             did not load"
        );
    }

    /// Every piece reads as a sentence beside its subject.
    ///
    /// The subject is a caller's own noun phrase and the piece is this
    /// module's, so the two meet in one line a person reads. The earlier
    /// wording put them adjacent and produced "the State client client
    /// certificate"; this pins that they no longer collide, for every piece
    /// rather than the one an easy-to-reach test happens to hit.
    #[test]
    fn every_identity_piece_reads_beside_its_subject() {
        for (part, expected) in [
            (
                IdentityPart::AuthorityCertificate,
                "the authority certificate for the State client at /x did not load",
            ),
            (
                IdentityPart::ClientCertificate,
                "the client certificate for the State client at /x did not load",
            ),
            (
                IdentityPart::ClientKey,
                "the client key for the State client at /x did not load",
            ),
            (
                IdentityPart::CertificateAndKey,
                "the certificate and key for the State client at /x did not load",
            ),
        ] {
            let err = ClientIdentityError {
                subject: "State client".to_owned(),
                part,
                path: "/x".to_owned(),
                source: Box::new(ClientIdentityCause::Parse("unused".to_owned())),
            };
            assert_eq!(err.to_string(), expected);
        }
    }

    #[test]
    fn an_empty_authority_bundle_is_refused_rather_than_trusting_nothing() {
        let dir = std::env::temp_dir();
        let ca = dir.join(format!("polyc-crypto-mtls-{}.pem", std::process::id()));
        std::fs::write(&ca, b"").unwrap();
        let err = mutual_tls_client_config("State client", &ca, &ca, &ca).unwrap_err();
        std::fs::remove_file(&ca).ok();
        assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
        assert!(
            matches!(
                err.cause(),
                ClientIdentityCause::Authority(CaLoadError::Empty { .. })
            ),
            "got {:?}",
            err.cause()
        );
    }
}