a73x

src/server/governance/cadir.rs

Ref:   Size: 5.7 KiB   History

//! `cadir/` — who may act as you.
//!
//! `keydir/` answers "which keys ARE this person"; `cadir/` answers "which
//! CAs may mint delegates OF this person". Same convention: the basename is
//! the name, directories are ignored, one trailing `.pub` is stripped.
//!
//! One deliberate rule difference from `keydir/`: the same CA key enrolled
//! under two names is ALLOWED here, not an error. In `keydir/` that is an
//! authorization coin-flip, because the connection presents only a
//! fingerprint and the server must pick a name. A certificate *names its
//! principal*, so the lookup runs the other way — "is this CA enrolled for
//! the name the cert claims" — and the same key under two names is simply a
//! shared CA that two people have each explicitly opted into.

use std::collections::HashMap;

use russh::keys::ssh_key::Fingerprint;
use russh::keys::{HashAlg, PublicKey};

use super::keydir::{name_for_path, validate_name};

#[derive(Debug, thiserror::Error)]
pub enum CaDirError {
    #[error("{path}: not a well-formed OpenSSH public key: {source}")]
    Malformed {
        path: String,
        #[source]
        source: russh::keys::ssh_key::Error,
    },
    #[error("{path}: {reason}")]
    BadName { path: String, reason: String },
}

/// Name-to-CA-fingerprints mapping built from `cadir/`.
#[derive(Debug, Default)]
pub struct CaDir {
    by_name: HashMap<String, Vec<Fingerprint>>,
}

impl CaDir {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn insert(&mut self, path: &str, content: &str) -> Result<(), CaDirError> {
        let Some(name) = name_for_path(path) else {
            // Not a .pub file; a README in cadir/ is not a CA.
            return Ok(());
        };
        validate_name(name).map_err(|reason| CaDirError::BadName {
            path: path.to_string(),
            reason,
        })?;

        let key =
            PublicKey::from_openssh(content.trim()).map_err(|source| CaDirError::Malformed {
                path: path.to_string(),
                source,
            })?;
        let fingerprint = key.fingerprint(HashAlg::Sha256);

        let entry = self.by_name.entry(name.to_string()).or_default();
        if !entry.contains(&fingerprint) {
            entry.push(fingerprint);
        }
        Ok(())
    }

    /// The CA fingerprints enrolled for `name`. Empty means no CA may mint
    /// delegates of this person.
    pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] {
        self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[])
    }

    /// Every name `cadir/` enrolls a CA for, for hygiene checks that walk the
    /// whole roster (e.g. a name with no matching `keydir/` entry).
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.by_name.keys().map(String::as_str)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Throwaway PUBLIC keys, same provenance discipline as keydir.rs's:
    /// expected fingerprints are `ssh-keygen -lf`'s answers, not ours.
    const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab";
    const FP_A: &str = "SHA256:h9V15zrr/EYDfNMPefKR+Gf2PpXdfw8M7Fvu9zLjjqY";
    const KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab";
    const FP_B: &str = "SHA256:GauEGK/qjDwFjqhK9/tOqTzcjmf48HCMgBUX6sddJg4";

    fn fp(s: &str) -> Fingerprint {
        s.parse().unwrap()
    }

    #[test]
    fn a_ca_at_the_top_level_authorizes_its_basename() {
        let mut cas = CaDir::new();
        cas.insert("cadir/alex.pub", KEY_A).unwrap();
        assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]);
        assert!(cas.fingerprints_for("mallory").is_empty());
    }

    #[test]
    fn directories_are_ignored() {
        let mut cas = CaDir::new();
        cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap();
        assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]);
    }

    /// The deliberate difference from keydir/: a shared CA is two explicit
    /// opt-ins, not an ambiguity.
    #[test]
    fn the_same_ca_under_two_names_is_allowed() {
        let mut cas = CaDir::new();
        cas.insert("cadir/shared/alex.pub", KEY_A).unwrap();
        cas.insert("cadir/shared/bob.pub", KEY_A).unwrap();
        assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]);
        assert_eq!(cas.fingerprints_for("bob"), &[fp(FP_A)]);
    }

    #[test]
    fn two_cas_for_one_name_are_both_kept() {
        let mut cas = CaDir::new();
        cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap();
        cas.insert("cadir/laptop/alex.pub", KEY_B).unwrap();
        let fps = cas.fingerprints_for("alex");
        assert!(fps.contains(&fp(FP_A)) && fps.contains(&fp(FP_B)));
    }

    #[test]
    fn the_same_file_twice_is_idempotent() {
        let mut cas = CaDir::new();
        cas.insert("cadir/alex.pub", KEY_A).unwrap();
        cas.insert("cadir/mirror/alex.pub", KEY_A).unwrap();
        assert_eq!(cas.fingerprints_for("alex").len(), 1);
    }

    #[test]
    fn a_malformed_key_is_rejected_naming_its_path() {
        let mut cas = CaDir::new();
        let err = cas.insert("cadir/alex.pub", "not a key").unwrap_err();
        assert!(err.to_string().contains("cadir/alex.pub"), "got {err}");
    }

    #[test]
    fn non_pub_files_are_ignored() {
        let mut cas = CaDir::new();
        cas.insert("cadir/README", "not a key").unwrap();
        assert!(cas.fingerprints_for("README").is_empty());
    }

    #[test]
    fn reserved_and_malformed_names_are_rejected() {
        let mut cas = CaDir::new();
        assert!(cas.insert("cadir/CREATOR.pub", KEY_A).is_err());
        assert!(cas.insert("cadir/@admins.pub", KEY_A).is_err());
        assert!(cas.insert("cadir/has space.pub", KEY_A).is_err());
    }
}