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