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