2cd07f5a
cadir/: which CAs may mint delegates of each person
a73x 2026-08-18 15:53
Commit message
src/server/governance/cadir.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,159 @@ | |||
| 1 | //! `cadir/` — who may act as you. | ||
| 2 | //! | ||
| 3 | //! `keydir/` answers "which keys ARE this person"; `cadir/` answers "which | ||
| 4 | //! CAs may mint delegates OF this person". Same convention: the basename is | ||
| 5 | //! the name, directories are ignored, one trailing `.pub` is stripped. | ||
| 6 | //! | ||
| 7 | //! One deliberate rule difference from `keydir/`: the same CA key enrolled | ||
| 8 | //! under two names is ALLOWED here, not an error. In `keydir/` that is an | ||
| 9 | //! authorization coin-flip, because the connection presents only a | ||
| 10 | //! fingerprint and the server must pick a name. A certificate *names its | ||
| 11 | //! principal*, so the lookup runs the other way — "is this CA enrolled for | ||
| 12 | //! the name the cert claims" — and the same key under two names is simply a | ||
| 13 | //! shared CA that two people have each explicitly opted into. | ||
| 14 | |||
| 15 | use std::collections::HashMap; | ||
| 16 | |||
| 17 | use russh::keys::ssh_key::Fingerprint; | ||
| 18 | use russh::keys::{HashAlg, PublicKey}; | ||
| 19 | |||
| 20 | use super::keydir::{name_for_path, validate_name}; | ||
| 21 | |||
| 22 | #[derive(Debug, thiserror::Error)] | ||
| 23 | pub enum CaDirError { | ||
| 24 | #[error("{path}: not a well-formed OpenSSH public key: {source}")] | ||
| 25 | Malformed { | ||
| 26 | path: String, | ||
| 27 | #[source] | ||
| 28 | source: russh::keys::ssh_key::Error, | ||
| 29 | }, | ||
| 30 | #[error("{path}: {reason}")] | ||
| 31 | BadName { path: String, reason: String }, | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Name-to-CA-fingerprints mapping built from `cadir/`. | ||
| 35 | #[derive(Debug, Default)] | ||
| 36 | pub struct CaDir { | ||
| 37 | by_name: HashMap<String, Vec<Fingerprint>>, | ||
| 38 | } | ||
| 39 | |||
| 40 | impl CaDir { | ||
| 41 | pub fn new() -> Self { | ||
| 42 | Self::default() | ||
| 43 | } | ||
| 44 | |||
| 45 | pub fn insert(&mut self, path: &str, content: &str) -> Result<(), CaDirError> { | ||
| 46 | let Some(name) = name_for_path(path) else { | ||
| 47 | // Not a .pub file; a README in cadir/ is not a CA. | ||
| 48 | return Ok(()); | ||
| 49 | }; | ||
| 50 | validate_name(name).map_err(|reason| CaDirError::BadName { | ||
| 51 | path: path.to_string(), | ||
| 52 | reason, | ||
| 53 | })?; | ||
| 54 | |||
| 55 | let key = | ||
| 56 | PublicKey::from_openssh(content.trim()).map_err(|source| CaDirError::Malformed { | ||
| 57 | path: path.to_string(), | ||
| 58 | source, | ||
| 59 | })?; | ||
| 60 | let fingerprint = key.fingerprint(HashAlg::Sha256); | ||
| 61 | |||
| 62 | let entry = self.by_name.entry(name.to_string()).or_default(); | ||
| 63 | if !entry.contains(&fingerprint) { | ||
| 64 | entry.push(fingerprint); | ||
| 65 | } | ||
| 66 | Ok(()) | ||
| 67 | } | ||
| 68 | |||
| 69 | /// The CA fingerprints enrolled for `name`. Empty means no CA may mint | ||
| 70 | /// delegates of this person. | ||
| 71 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { | ||
| 72 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn is_empty(&self) -> bool { | ||
| 76 | self.by_name.is_empty() | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | #[cfg(test)] | ||
| 81 | mod tests { | ||
| 82 | use super::*; | ||
| 83 | |||
| 84 | /// Throwaway PUBLIC keys, same provenance discipline as keydir.rs's: | ||
| 85 | /// expected fingerprints are `ssh-keygen -lf`'s answers, not ours. | ||
| 86 | const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab"; | ||
| 87 | const FP_A: &str = "SHA256:h9V15zrr/EYDfNMPefKR+Gf2PpXdfw8M7Fvu9zLjjqY"; | ||
| 88 | const KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab"; | ||
| 89 | const FP_B: &str = "SHA256:GauEGK/qjDwFjqhK9/tOqTzcjmf48HCMgBUX6sddJg4"; | ||
| 90 | |||
| 91 | fn fp(s: &str) -> Fingerprint { | ||
| 92 | s.parse().unwrap() | ||
| 93 | } | ||
| 94 | |||
| 95 | #[test] | ||
| 96 | fn a_ca_at_the_top_level_authorizes_its_basename() { | ||
| 97 | let mut cas = CaDir::new(); | ||
| 98 | cas.insert("cadir/alex.pub", KEY_A).unwrap(); | ||
| 99 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 100 | assert!(cas.fingerprints_for("mallory").is_empty()); | ||
| 101 | } | ||
| 102 | |||
| 103 | #[test] | ||
| 104 | fn directories_are_ignored() { | ||
| 105 | let mut cas = CaDir::new(); | ||
| 106 | cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap(); | ||
| 107 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 108 | } | ||
| 109 | |||
| 110 | /// The deliberate difference from keydir/: a shared CA is two explicit | ||
| 111 | /// opt-ins, not an ambiguity. | ||
| 112 | #[test] | ||
| 113 | fn the_same_ca_under_two_names_is_allowed() { | ||
| 114 | let mut cas = CaDir::new(); | ||
| 115 | cas.insert("cadir/shared/alex.pub", KEY_A).unwrap(); | ||
| 116 | cas.insert("cadir/shared/bob.pub", KEY_A).unwrap(); | ||
| 117 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 118 | assert_eq!(cas.fingerprints_for("bob"), &[fp(FP_A)]); | ||
| 119 | } | ||
| 120 | |||
| 121 | #[test] | ||
| 122 | fn two_cas_for_one_name_are_both_kept() { | ||
| 123 | let mut cas = CaDir::new(); | ||
| 124 | cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap(); | ||
| 125 | cas.insert("cadir/laptop/alex.pub", KEY_B).unwrap(); | ||
| 126 | let fps = cas.fingerprints_for("alex"); | ||
| 127 | assert!(fps.contains(&fp(FP_A)) && fps.contains(&fp(FP_B))); | ||
| 128 | } | ||
| 129 | |||
| 130 | #[test] | ||
| 131 | fn the_same_file_twice_is_idempotent() { | ||
| 132 | let mut cas = CaDir::new(); | ||
| 133 | cas.insert("cadir/alex.pub", KEY_A).unwrap(); | ||
| 134 | cas.insert("cadir/mirror/alex.pub", KEY_A).unwrap(); | ||
| 135 | assert_eq!(cas.fingerprints_for("alex").len(), 1); | ||
| 136 | } | ||
| 137 | |||
| 138 | #[test] | ||
| 139 | fn a_malformed_key_is_rejected_naming_its_path() { | ||
| 140 | let mut cas = CaDir::new(); | ||
| 141 | let err = cas.insert("cadir/alex.pub", "not a key").unwrap_err(); | ||
| 142 | assert!(err.to_string().contains("cadir/alex.pub"), "got {err}"); | ||
| 143 | } | ||
| 144 | |||
| 145 | #[test] | ||
| 146 | fn non_pub_files_are_ignored() { | ||
| 147 | let mut cas = CaDir::new(); | ||
| 148 | cas.insert("cadir/README", "not a key").unwrap(); | ||
| 149 | assert!(cas.is_empty()); | ||
| 150 | } | ||
| 151 | |||
| 152 | #[test] | ||
| 153 | fn reserved_and_malformed_names_are_rejected() { | ||
| 154 | let mut cas = CaDir::new(); | ||
| 155 | assert!(cas.insert("cadir/CREATOR.pub", KEY_A).is_err()); | ||
| 156 | assert!(cas.insert("cadir/@admins.pub", KEY_A).is_err()); | ||
| 157 | assert!(cas.insert("cadir/has space.pub", KEY_A).is_err()); | ||
| 158 | } | ||
| 159 | } | ||
src/server/governance/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -71,6 +71,7 @@ | |||
| 71 | //! to the binary — flipping it globally would silently hide every repository | 71 | //! to the binary — flipping it globally would silently hide every repository |
| 72 | //! on every deployment that upgraded. | 72 | //! on every deployment that upgraded. |
| 73 | 73 | ||
| 74 | pub mod cadir; | ||
| 74 | pub mod conf; | 75 | pub mod conf; |
| 75 | pub mod hook; | 76 | pub mod hook; |
| 76 | pub mod keydir; | 77 | pub mod keydir; |