a73x

src/server/governance/keydir.rs

Ref:   Size: 9.5 KiB   History

//! `keydir/` — the roster of public keys held in `settings.git`.
//!
//! The identity is the **basename** of the key file with directories ignored,
//! as in gitolite: `keydir/laptop/alex.pub` and `keydir/desktop/alex.pub` are
//! both `alex`. That is the whole answer to one operator with several machines
//! — adding a device is adding a file, revoking one is `git rm`, and the diff
//! is legible in review.
//!
//! On the wire the principal is still the key fingerprint. This maps
//! fingerprint to name, so names appear in `conf/access.conf` and fingerprints
//! appear on the connection. A fingerprint with no name here is not a
//! principal at all.

use std::collections::HashMap;

use russh::keys::PublicKey;

#[derive(Debug, thiserror::Error)]
pub enum KeyDirError {
    #[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 },
    #[error(
        "{path}: this key is already enrolled as {existing:?}; one key cannot be two principals"
    )]
    Ambiguous { path: String, existing: String },
}

/// Fingerprint-to-name mapping built from `keydir/`.
#[derive(Debug, Default)]
pub struct KeyDir {
    /// Principal string (`key:SHA256:…`) to the name it resolves to.
    by_fingerprint: HashMap<String, String>,
    /// Every distinct name, for validation questions like "does anyone still
    /// hold RW+ on settings".
    names: Vec<String>,
}

/// The name a key file grants, from its path.
///
/// Directories are ignored entirely and one trailing `.pub` is stripped. A
/// path that is not a `.pub` file grants nothing — `keydir/README` is not an
/// identity.
pub fn name_for_path(path: &str) -> Option<&str> {
    let file = path.rsplit('/').next()?;
    let name = file.strip_suffix(".pub")?;
    if name.is_empty() {
        return None;
    }
    Some(name)
}

/// Names must be usable as literal tokens in `conf/access.conf`, so they carry
/// the same character set as a principal there and must not look like a group
/// reference or the `CREATOR` keyword.
///
/// Public to the crate because `setup` derives a name from a filename and has
/// to reject a bad one at the point the operator can still fix it, rather than
/// enrolling a key nobody can write a rule for.
pub(crate) fn validate_name(name: &str) -> Result<(), String> {
    if name == "CREATOR" {
        return Err("CREATOR is a reserved keyword and cannot name a key".to_string());
    }
    if name.starts_with('@') {
        return Err("a key name cannot start with @; that is group syntax".to_string());
    }
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | '+'))
    {
        return Err(format!(
            "{name:?} contains characters that cannot appear in a principal name"
        ));
    }
    Ok(())
}

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

    /// Enrol one key file. `path` is the path within the settings repository,
    /// e.g. `keydir/laptop/alex.pub`; only its basename decides the name.
    ///
    /// Enrolling the same key under the same name twice is a no-op (two
    /// identical files, which is harmless). Enrolling it under two *different*
    /// names is an error: a fingerprint that resolved to either name depending
    /// on iteration order would be an authorization coin-flip.
    pub fn insert(&mut self, path: &str, content: &str) -> Result<(), KeyDirError> {
        let Some(name) = name_for_path(path) else {
            // Not a .pub file. Silently ignored, so a README or a .gitkeep in
            // keydir/ does not fail an otherwise valid config push.
            return Ok(());
        };
        validate_name(name).map_err(|reason| KeyDirError::BadName {
            path: path.to_string(),
            reason,
        })?;

        let key =
            PublicKey::from_openssh(content.trim()).map_err(|source| KeyDirError::Malformed {
                path: path.to_string(),
                source,
            })?;
        let fingerprint = crate::ssh::session::ssh_key_principal(&key);

        match self.by_fingerprint.get(&fingerprint) {
            Some(existing) if existing == name => return Ok(()),
            Some(existing) => {
                return Err(KeyDirError::Ambiguous {
                    path: path.to_string(),
                    existing: existing.clone(),
                })
            }
            None => {}
        }

        self.by_fingerprint.insert(fingerprint, name.to_string());
        if !self.names.iter().any(|n| n == name) {
            self.names.push(name.to_string());
        }
        Ok(())
    }

    /// The name this principal string resolves to, or `None` if the key is not
    /// enrolled — in which case it is not a principal and gets no access.
    pub fn name_for(&self, principal: &str) -> Option<&str> {
        self.by_fingerprint.get(principal).map(String::as_str)
    }

    pub fn names(&self) -> &[String] {
        &self.names
    }

    pub fn is_empty(&self) -> bool {
        self.by_fingerprint.is_empty()
    }
}

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

    /// Throwaway PUBLIC keys generated for these tests. They guard nothing and
    /// have no matching private key anywhere in the tree.
    ///
    /// The expected fingerprints are OpenSSH's own answers, taken out of band
    /// with `ssh-keygen -lf`, not values computed the way the code computes
    /// them — so this asserts agreement with an external oracle rather than
    /// self-consistency.
    const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab";
    const FP_A: &str = "key:SHA256:h9V15zrr/EYDfNMPefKR+Gf2PpXdfw8M7Fvu9zLjjqY";
    const KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab";
    const FP_B: &str = "key:SHA256:GauEGK/qjDwFjqhK9/tOqTzcjmf48HCMgBUX6sddJg4";

    #[test]
    fn a_key_at_the_top_level_is_named_by_its_basename() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", KEY_A).unwrap();
        assert_eq!(dir.name_for(FP_A), Some("alex"));
    }

    /// The load-bearing case: one operator, two machines, one identity.
    #[test]
    fn two_keys_in_different_directories_are_one_principal() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/laptop/alex.pub", KEY_A).unwrap();
        dir.insert("keydir/desktop/alex.pub", KEY_B).unwrap();

        assert_eq!(dir.name_for(FP_A), Some("alex"));
        assert_eq!(dir.name_for(FP_B), Some("alex"));
        assert_eq!(dir.names(), ["alex"], "one identity, not two");
    }

    #[test]
    fn different_basenames_are_different_principals() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", KEY_A).unwrap();
        dir.insert("keydir/claude-a.pub", KEY_B).unwrap();
        assert_eq!(dir.name_for(FP_A), Some("alex"));
        assert_eq!(dir.name_for(FP_B), Some("claude-a"));
        assert_eq!(dir.names().len(), 2);
    }

    #[test]
    fn nesting_depth_is_irrelevant() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/a/b/c/alex.pub", KEY_A).unwrap();
        assert_eq!(dir.name_for(FP_A), Some("alex"));
    }

    #[test]
    fn an_unenrolled_fingerprint_resolves_to_nothing() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", KEY_A).unwrap();
        assert_eq!(dir.name_for(FP_B), None);
        assert_eq!(dir.name_for("key:SHA256:nonsense"), None);
    }

    #[test]
    fn the_same_file_twice_is_idempotent() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", KEY_A).unwrap();
        dir.insert("keydir/laptop/alex.pub", KEY_A).unwrap();
        assert_eq!(dir.names(), ["alex"]);
    }

    #[test]
    fn one_key_under_two_names_is_rejected() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", KEY_A).unwrap();
        let err = dir.insert("keydir/mallory.pub", KEY_A).unwrap_err();
        assert!(err.to_string().contains("alex"), "got {err}");
    }

    #[test]
    fn a_malformed_key_is_rejected() {
        let mut dir = KeyDir::new();
        let err = dir
            .insert("keydir/alex.pub", "not a key at all")
            .unwrap_err();
        assert!(err.to_string().contains("keydir/alex.pub"), "got {err}");
    }

    #[test]
    fn non_pub_files_are_ignored() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/README", "this is not a key").unwrap();
        dir.insert("keydir/.gitkeep", "").unwrap();
        assert!(dir.is_empty());
    }

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

    #[test]
    fn trailing_whitespace_in_a_key_file_is_tolerated() {
        let mut dir = KeyDir::new();
        dir.insert("keydir/alex.pub", &format!("{KEY_A}\n\n"))
            .unwrap();
        assert_eq!(dir.name_for(FP_A), Some("alex"));
    }

    #[test]
    fn name_for_path_ignores_directories_and_requires_pub() {
        assert_eq!(name_for_path("keydir/alex.pub"), Some("alex"));
        assert_eq!(name_for_path("keydir/laptop/alex.pub"), Some("alex"));
        assert_eq!(name_for_path("alex.pub"), Some("alex"));
        assert_eq!(name_for_path("keydir/alex"), None);
        assert_eq!(name_for_path("keydir/.pub"), None);
    }
}