a73x

src/server/governance/delegate.rs

Ref:   Size: 20.8 KiB   History

//! Delegate certificates: the two policy decisions, in one place.
//!
//! A certificate is a delegate of the person it names. `validate` is the
//! entire answer to "is this certificate a valid delegate right now" — auth
//! calls it when the connection opens, and the regime calls it again on every
//! subsequent command, which is what makes revocation (cadir/ entry removed,
//! person's keys removed, cert expired) take effect on the next command
//! rather than the next connection.
//!
//! `permits` is the entire answer to the second question, "may a delegate ask
//! for this at all" — the ceiling. The dispatcher routes every exec request
//! through it, so a command shape nobody has thought about yet is refused
//! rather than allowed by omission.

use russh::keys::ssh_key::certificate::CertType;
use russh::keys::ssh_key::Certificate;

use super::Governance;
use crate::ssh::session::{ExecCommand, GitCmd, LeaseCmd, ReleaseCmd};

#[derive(Debug)]
pub struct Delegate {
    pub person: String,
    pub key_id: String,
}

pub fn validate(
    cert: &Certificate,
    governance: &Governance,
    unix_now: u64,
) -> Result<Delegate, String> {
    if cert.cert_type() != CertType::User {
        return Err("not a user certificate".to_string());
    }

    // Exactly one principal: a delegate acts for one person. Zero is
    // OpenSSH's "valid for anyone", which is an anti-goal here.
    let person = match cert.valid_principals() {
        [one] => one.clone(),
        [] => return Err("certificate names no principal".to_string()),
        many => return Err(format!("certificate names {} principals", many.len())),
    };

    // Everything from here up to validate_at below is unauthenticated input —
    // the cert's own claims, taken before its signature is checked — so it
    // must be Debug-quoted wherever it reaches an error message, the same as
    // `name` is at the critical-options check below.
    // cadir/ delegates identity; it never creates it. The person must exist.
    if !governance.keys.names().contains(&person) {
        return Err(format!("{person:?} is not enrolled in keydir/"));
    }

    // Per PROTOCOL.certkeys, an implementation MUST refuse a certificate
    // carrying a critical option it does not recognize. We recognize none.
    if let Some((name, _)) = cert.critical_options().iter().next() {
        return Err(format!("unrecognized critical option {name:?}"));
    }

    // Signature verifies, signing CA is enrolled *for this person*, and the
    // timestamp is inside the validity window — all three via validate_at.
    let fingerprints = governance.cas.fingerprints_for(&person);
    if fingerprints.is_empty() {
        return Err(format!("no CA is enrolled in cadir/ for {person:?}"));
    }
    cert.validate_at(unix_now, fingerprints.iter())
        .map_err(|e| format!("certificate did not validate for {person}: {e}"))?;

    // key_id is the CA's choice, not ours, and it reaches a client-visible
    // hook string, a child process env var, and the log — all unescaped. An
    // OpenSSH cert places no limit on it (ssh-keygen -I takes any string,
    // newlines included), so refuse anything that isn't a short, printable
    // ASCII token before it goes anywhere.
    let key_id = cert.key_id().to_string();
    if key_id.is_empty() {
        return Err("certificate key ID is empty".to_string());
    }
    if key_id.len() > 64 {
        return Err(format!(
            "certificate key ID is {} bytes, longer than 64",
            key_id.len()
        ));
    }
    if !key_id.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
        return Err("certificate key ID contains a character outside printable ASCII".to_string());
    }

    Ok(Delegate { person, key_id })
}

/// What a command amounts to, at the granularity the delegate ceiling judges.
///
/// Coarser than `ExecCommand` on purpose: the ceiling is a policy about kinds
/// of act, not about argument shapes, so `upload` and `delete` are one thing
/// here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
    /// `git-upload-pack`: read a repository.
    Fetch,
    /// `git-receive-pack` into a repository that already exists. *Which* refs
    /// may move is a separate question, answered by the update hook in the
    /// receive-pack process.
    Push,
    /// `git-receive-pack` into a repository that does not exist yet, which is
    /// the `C` permission rather than a write to anything.
    CreateRepo,
    /// `collab-release list`.
    ReleaseList,
    /// `collab-release upload` or `delete`.
    ReleaseMutate,
    /// `collab-lease list`: read who holds what.
    LeaseList,
    /// `collab-lease acquire`, `renew` or `release`: claim or yield work.
    LeaseMutate,
}

/// Classify an exec request, given whether its repository exists yet.
///
/// Exhaustive with no wildcard arm: a new `ExecCommand` shape does not compile
/// until someone says which action it is, which is what stops a new verb from
/// reaching the ceiling unclassified.
pub fn action_of(command: &ExecCommand, repo_exists: bool) -> Action {
    match command {
        ExecCommand::Git {
            cmd: GitCmd::UploadPack,
            ..
        } => Action::Fetch,
        ExecCommand::Git {
            cmd: GitCmd::ReceivePack,
            ..
        } if repo_exists => Action::Push,
        ExecCommand::Git {
            cmd: GitCmd::ReceivePack,
            ..
        } => Action::CreateRepo,
        ExecCommand::Release(ReleaseCmd::List { .. }) => Action::ReleaseList,
        ExecCommand::Release(ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. }) => {
            Action::ReleaseMutate
        }
        ExecCommand::Lease(LeaseCmd::List { .. }) => Action::LeaseList,
        ExecCommand::Lease(
            LeaseCmd::Acquire { .. } | LeaseCmd::Renew { .. } | LeaseCmd::Release { .. },
        ) => Action::LeaseMutate,
    }
}

/// May a delegate certificate do this at all?
///
/// The whole ceiling, in one expression the dispatcher must pass through: a
/// delegate reads what its person reads and writes into `refs/collab/*`;
/// creating repositories and publishing releases stay the person's own. The
/// per-ref half of the push answer lives in the update hook (`hook::run`),
/// which runs in the receive-pack process and cannot call this.
///
/// Exhaustive with no wildcard arm, again on purpose: a new `Action` has no
/// default answer, so widening the ceiling has to be a deliberate edit here
/// rather than a side effect of adding a command.
pub fn permits(action: Action) -> bool {
    match action {
        Action::Fetch | Action::Push | Action::ReleaseList => true,
        // Claiming and yielding work is a collaboration act — the same kind
        // of thing as writing refs/collab/* — so it sits under the ceiling.
        Action::LeaseList | Action::LeaseMutate => true,
        Action::CreateRepo | Action::ReleaseMutate => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;
    use std::process::Command;

    /// Generate a keypair; returns (private_path, public_content).
    fn keygen(dir: &Path, name: &str) -> (std::path::PathBuf, String) {
        let key = dir.join(name);
        let out = Command::new("ssh-keygen")
            .args(["-t", "ed25519", "-N", "", "-q", "-C", name])
            .arg("-f")
            .arg(&key)
            .output()
            .unwrap();
        assert!(
            out.status.success(),
            "{}",
            String::from_utf8_lossy(&out.stderr)
        );
        let public = std::fs::read_to_string(key.with_extension("pub")).unwrap();
        (key, public)
    }

    /// Mint a certificate with ssh-keygen. `extra` lets a test pass flags like
    /// ["-h"] (host cert) or ["-O", "force-command=/bin/true"] (critical opt).
    /// `principals`: None = valid-for-anyone (no -n flag).
    fn mint(
        ca: &Path,
        subject_pub: &Path,
        key_id: &str,
        principals: Option<&str>,
        validity: &str,
        extra: &[&str],
    ) -> Certificate {
        let mut cmd = Command::new("ssh-keygen");
        cmd.arg("-s")
            .arg(ca)
            .args(["-I", key_id, "-V", validity])
            .args(extra);
        if let Some(p) = principals {
            cmd.args(["-n", p]);
        }
        cmd.arg(subject_pub);
        let out = cmd.output().unwrap();
        assert!(
            out.status.success(),
            "{}",
            String::from_utf8_lossy(&out.stderr)
        );
        let cert_path = subject_pub.to_str().unwrap().replace(".pub", "-cert.pub");
        let text = std::fs::read_to_string(&cert_path).unwrap();
        std::fs::remove_file(&cert_path).unwrap(); // ssh-keygen refuses to overwrite
        text.trim().parse().unwrap()
    }

    /// A Governance where `person` is enrolled in keydir/ and `ca_pub` (if
    /// given) is enrolled for `ca_for` in cadir/.
    fn governance(person_pub: &str, person: &str, ca_pub: Option<(&str, &str)>) -> Governance {
        let conf = crate::governance::conf::AccessConf::parse(&format!(
            "repo settings\n    RW+ = {person}\n"
        ))
        .unwrap();
        let mut keys = crate::governance::keydir::KeyDir::new();
        keys.insert(&format!("keydir/{person}.pub"), person_pub)
            .unwrap();
        let mut cas = crate::governance::cadir::CaDir::new();
        if let Some((content, name)) = ca_pub {
            cas.insert(&format!("cadir/{name}.pub"), content).unwrap();
        }
        Governance { conf, keys, cas }
    }

    // 2026-08-18T00:00:00Z: inside VALID_WINDOW (2026-01-01..2027-01-01) and
    // after EXPIRED_WINDOW (2020-01-01..2021-01-01). Fixed, not wall-clock.
    const NOW: u64 = 1_787_011_200;
    const VALID_WINDOW: &str = "20260101000000:20270101000000";
    const EXPIRED_WINDOW: &str = "20200101000000:20210101000000";

    #[test]
    fn a_cert_from_an_enrolled_ca_naming_an_enrolled_person_validates() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let delegate = validate(&cert, &gov, NOW).expect("must validate");
        assert_eq!(delegate.person, "alex");
        assert_eq!(delegate.key_id, "claude-a");
    }

    #[test]
    fn a_cert_from_an_unenrolled_ca_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, _) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", None);
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(
            err.contains("alex") && err.to_lowercase().contains("ca"),
            "got {err}"
        );
    }

    #[test]
    fn a_ca_enrolled_for_a_different_name_cannot_mint_for_this_one() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "bob")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("alex"), "got {err}");
    }

    #[test]
    fn an_unenrolled_principal_is_rejected_even_from_a_trusted_ca() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (_alex_key, alex_pub) = keygen(tmp.path(), "alex");
        let (mallory_key, _mallory_pub) = keygen(tmp.path(), "mallory");
        let gov = governance(&alex_pub, "alex", Some((&ca_pub, "mallory")));
        let cert = mint(
            &ca,
            &mallory_key.with_extension("pub"),
            "claude-a",
            Some("mallory"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("mallory"), "got {err}");
    }

    #[test]
    fn zero_principals_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            None,
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("no principal"), "got {err}");
    }

    #[test]
    fn two_principals_are_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex,bob"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("2 principals"), "got {err}");
    }

    #[test]
    fn a_host_certificate_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            VALID_WINDOW,
            &["-h"],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("user certificate"), "got {err}");
    }

    #[test]
    fn an_unknown_critical_option_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            VALID_WINDOW,
            &["-O", "force-command=/bin/true"],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("critical option"), "got {err}");
    }

    #[test]
    fn an_expired_certificate_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "claude-a",
            Some("alex"),
            EXPIRED_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("did not validate"), "got {err}");
    }

    #[test]
    fn an_empty_key_id_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "",
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("key ID"), "got {err}");
    }

    #[test]
    fn a_key_id_over_64_bytes_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        let long_id = "x".repeat(65);
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            &long_id,
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("key ID"), "got {err}");
    }

    #[test]
    fn a_key_id_containing_a_newline_is_rejected() {
        let tmp = tempfile::TempDir::new().unwrap();
        let (ca, ca_pub) = keygen(tmp.path(), "ca");
        let (person_key, person_pub) = keygen(tmp.path(), "alex");
        let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
        // ssh-keygen -I takes any string, newline included — no shell is
        // involved (it's a single argv element), so this is the external
        // oracle for "a hostile CA can put a newline in a key ID", not
        // something our own test setup invented.
        let cert = mint(
            &ca,
            &person_key.with_extension("pub"),
            "evil\nfake-line",
            Some("alex"),
            VALID_WINDOW,
            &[],
        );

        let err = validate(&cert, &gov, NOW).unwrap_err();
        assert!(err.contains("key ID"), "got {err}");
    }

    /// Every command shape the dispatcher can build, and the ceiling's answer
    /// for each. `action_of` and `permits` are exhaustive matches, so a new
    /// command or action breaks the build; this table is what makes the
    /// *answer* visible rather than merely decided.
    #[test]
    fn the_ceiling_answers_every_command_shape() {
        let repo = || "r.git".to_string();
        let cases: &[(ExecCommand, bool, Action, bool)] = &[
            (
                ExecCommand::Git {
                    cmd: GitCmd::UploadPack,
                    repo: repo(),
                },
                true,
                Action::Fetch,
                true,
            ),
            (
                ExecCommand::Git {
                    cmd: GitCmd::UploadPack,
                    repo: repo(),
                },
                false,
                Action::Fetch,
                true,
            ),
            (
                ExecCommand::Git {
                    cmd: GitCmd::ReceivePack,
                    repo: repo(),
                },
                true,
                Action::Push,
                true,
            ),
            (
                ExecCommand::Git {
                    cmd: GitCmd::ReceivePack,
                    repo: repo(),
                },
                false,
                Action::CreateRepo,
                false,
            ),
            (
                ExecCommand::Release(ReleaseCmd::List { repo: repo() }),
                true,
                Action::ReleaseList,
                true,
            ),
            (
                ExecCommand::Release(ReleaseCmd::Upload {
                    repo: repo(),
                    version: "v1".to_string(),
                    filename: "a.tar.gz".to_string(),
                    force: false,
                }),
                true,
                Action::ReleaseMutate,
                false,
            ),
            (
                ExecCommand::Release(ReleaseCmd::Upload {
                    repo: repo(),
                    version: "v1".to_string(),
                    filename: "a.tar.gz".to_string(),
                    force: true,
                }),
                true,
                Action::ReleaseMutate,
                false,
            ),
            (
                ExecCommand::Release(ReleaseCmd::Delete {
                    repo: repo(),
                    version: "v1".to_string(),
                    filename: None,
                }),
                true,
                Action::ReleaseMutate,
                false,
            ),
            (
                ExecCommand::Release(ReleaseCmd::Delete {
                    repo: repo(),
                    version: "v1".to_string(),
                    filename: Some("a.tar.gz".to_string()),
                }),
                true,
                Action::ReleaseMutate,
                false,
            ),
        ];

        for (command, repo_exists, expected, permitted) in cases {
            let action = action_of(command, *repo_exists);
            assert_eq!(
                action, *expected,
                "{command:?} with repo_exists={repo_exists}"
            );
            assert_eq!(permits(action), *permitted, "{action:?}");
        }
    }
}