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());
}
}