a73x

2cd07f5a

cadir/: which CAs may mint delegates of each person

a73x   2026-08-18 15:53

Commit message
cadir/: which CAs may mint delegates of each person

keydir/ resolves a connecting key's fingerprint to a name, so the same
fingerprint enrolled under two names is an unresolvable coin-flip and
must be rejected. cadir/ answers the opposite question — a certificate
already names its principal, so lookup is by name and the same CA key
enrolled for two people is just two explicit opt-ins, not an
ambiguity. That's the one place this module deliberately diverges from
keydir/'s structure and error set (no Ambiguous variant).

Not yet wired into Governance; a later task in this feature reads
cadir/ off the settings tree the way read_keydir does today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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;