a73x

1d5295ea

Validate a certificate into a delegate of an enrolled person

a73x   2026-08-18 16:52

Commit message
Validate a certificate into a delegate of an enrolled person

delegate::validate is the whole certificate policy in one function:
user cert, exactly one principal, that principal enrolled in keydir/,
no unrecognized critical options, and a valid signature from a CA
enrolled in cadir/ for that person, all within the cert's validity
window. Auth calls it on connect; the regime calls it again on every
command, which is what makes revocation take effect without a
reconnect.

Tests mint real certificates with ssh-keygen (external oracle) rather
than asserting against our own encoding of the cert format.

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

src/server/governance/delegate.rs
Old New
@@ -0,0 +1,318 @@
1 //! Delegate certificates: the one policy decision, in one place.
2 //!
3 //! A certificate is a delegate of the person it names. This function is the
4 //! entire answer to "is this certificate a valid delegate right now" — auth
5 //! calls it when the connection opens, and the regime calls it again on every
6 //! subsequent command, which is what makes revocation (cadir/ entry removed,
7 //! person's keys removed, cert expired) take effect on the next command
8 //! rather than the next connection.
9
10 use russh::keys::ssh_key::certificate::CertType;
11 use russh::keys::ssh_key::Certificate;
12
13 use super::Governance;
14
15 #[derive(Debug)]
16 pub struct Delegate {
17 pub person: String,
18 pub key_id: String,
19 }
20
21 pub fn validate(
22 cert: &Certificate,
23 governance: &Governance,
24 unix_now: u64,
25 ) -> Result<Delegate, String> {
26 if cert.cert_type() != CertType::User {
27 return Err("not a user certificate".to_string());
28 }
29
30 // Exactly one principal: a delegate acts for one person. Zero is
31 // OpenSSH's "valid for anyone", which is an anti-goal here.
32 let person = match cert.valid_principals() {
33 [one] => one.clone(),
34 [] => return Err("certificate names no principal".to_string()),
35 many => return Err(format!("certificate names {} principals", many.len())),
36 };
37
38 // cadir/ delegates identity; it never creates it. The person must exist.
39 if !governance.keys.names().contains(&person) {
40 return Err(format!("{person} is not enrolled in keydir/"));
41 }
42
43 // Per PROTOCOL.certkeys, an implementation MUST refuse a certificate
44 // carrying a critical option it does not recognize. We recognize none.
45 if let Some((name, _)) = cert.critical_options().iter().next() {
46 return Err(format!("unrecognized critical option {name:?}"));
47 }
48
49 // Signature verifies, signing CA is enrolled *for this person*, and the
50 // timestamp is inside the validity window — all three via validate_at.
51 let fingerprints = governance.cas.fingerprints_for(&person);
52 if fingerprints.is_empty() {
53 return Err(format!("no CA is enrolled in cadir/ for {person}"));
54 }
55 cert.validate_at(unix_now, fingerprints.iter())
56 .map_err(|e| format!("certificate did not validate for {person}: {e}"))?;
57
58 Ok(Delegate {
59 person,
60 key_id: cert.key_id().to_string(),
61 })
62 }
63
64 #[cfg(test)]
65 mod tests {
66 use super::*;
67 use std::path::Path;
68 use std::process::Command;
69
70 /// Generate a keypair; returns (private_path, public_content).
71 fn keygen(dir: &Path, name: &str) -> (std::path::PathBuf, String) {
72 let key = dir.join(name);
73 let out = Command::new("ssh-keygen")
74 .args(["-t", "ed25519", "-N", "", "-q", "-C", name])
75 .arg("-f")
76 .arg(&key)
77 .output()
78 .unwrap();
79 assert!(
80 out.status.success(),
81 "{}",
82 String::from_utf8_lossy(&out.stderr)
83 );
84 let public = std::fs::read_to_string(key.with_extension("pub")).unwrap();
85 (key, public)
86 }
87
88 /// Mint a certificate with ssh-keygen. `extra` lets a test pass flags like
89 /// ["-h"] (host cert) or ["-O", "force-command=/bin/true"] (critical opt).
90 /// `principals`: None = valid-for-anyone (no -n flag).
91 fn mint(
92 ca: &Path,
93 subject_pub: &Path,
94 key_id: &str,
95 principals: Option<&str>,
96 validity: &str,
97 extra: &[&str],
98 ) -> Certificate {
99 let mut cmd = Command::new("ssh-keygen");
100 cmd.arg("-s")
101 .arg(ca)
102 .args(["-I", key_id, "-V", validity])
103 .args(extra);
104 if let Some(p) = principals {
105 cmd.args(["-n", p]);
106 }
107 cmd.arg(subject_pub);
108 let out = cmd.output().unwrap();
109 assert!(
110 out.status.success(),
111 "{}",
112 String::from_utf8_lossy(&out.stderr)
113 );
114 let cert_path = subject_pub.to_str().unwrap().replace(".pub", "-cert.pub");
115 let text = std::fs::read_to_string(&cert_path).unwrap();
116 std::fs::remove_file(&cert_path).unwrap(); // ssh-keygen refuses to overwrite
117 text.trim().parse().unwrap()
118 }
119
120 /// A Governance where `person` is enrolled in keydir/ and `ca_pub` (if
121 /// given) is enrolled for `ca_for` in cadir/.
122 fn governance(person_pub: &str, person: &str, ca_pub: Option<(&str, &str)>) -> Governance {
123 let conf = crate::governance::conf::AccessConf::parse(&format!(
124 "repo settings\n RW+ = {person}\n"
125 ))
126 .unwrap();
127 let mut keys = crate::governance::keydir::KeyDir::new();
128 keys.insert(&format!("keydir/{person}.pub"), person_pub)
129 .unwrap();
130 let mut cas = crate::governance::cadir::CaDir::new();
131 if let Some((content, name)) = ca_pub {
132 cas.insert(&format!("cadir/{name}.pub"), content).unwrap();
133 }
134 Governance { conf, keys, cas }
135 }
136
137 // 2026-08-18T00:00:00Z: inside VALID_WINDOW (2026-01-01..2027-01-01) and
138 // after EXPIRED_WINDOW (2020-01-01..2021-01-01). Fixed, not wall-clock.
139 const NOW: u64 = 1_787_011_200;
140 const VALID_WINDOW: &str = "20260101000000:20270101000000";
141 const EXPIRED_WINDOW: &str = "20200101000000:20210101000000";
142
143 #[test]
144 fn a_cert_from_an_enrolled_ca_naming_an_enrolled_person_validates() {
145 let tmp = tempfile::TempDir::new().unwrap();
146 let (ca, ca_pub) = keygen(tmp.path(), "ca");
147 let (person_key, person_pub) = keygen(tmp.path(), "alex");
148 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
149 let cert = mint(
150 &ca,
151 &person_key.with_extension("pub"),
152 "claude-a",
153 Some("alex"),
154 VALID_WINDOW,
155 &[],
156 );
157
158 let delegate = validate(&cert, &gov, NOW).expect("must validate");
159 assert_eq!(delegate.person, "alex");
160 assert_eq!(delegate.key_id, "claude-a");
161 }
162
163 #[test]
164 fn a_cert_from_an_unenrolled_ca_is_rejected() {
165 let tmp = tempfile::TempDir::new().unwrap();
166 let (ca, _) = keygen(tmp.path(), "ca");
167 let (person_key, person_pub) = keygen(tmp.path(), "alex");
168 let gov = governance(&person_pub, "alex", None);
169 let cert = mint(
170 &ca,
171 &person_key.with_extension("pub"),
172 "claude-a",
173 Some("alex"),
174 VALID_WINDOW,
175 &[],
176 );
177
178 let err = validate(&cert, &gov, NOW).unwrap_err();
179 assert!(
180 err.contains("alex") && err.to_lowercase().contains("ca"),
181 "got {err}"
182 );
183 }
184
185 #[test]
186 fn a_ca_enrolled_for_a_different_name_cannot_mint_for_this_one() {
187 let tmp = tempfile::TempDir::new().unwrap();
188 let (ca, ca_pub) = keygen(tmp.path(), "ca");
189 let (person_key, person_pub) = keygen(tmp.path(), "alex");
190 let gov = governance(&person_pub, "alex", Some((&ca_pub, "bob")));
191 let cert = mint(
192 &ca,
193 &person_key.with_extension("pub"),
194 "claude-a",
195 Some("alex"),
196 VALID_WINDOW,
197 &[],
198 );
199
200 let err = validate(&cert, &gov, NOW).unwrap_err();
201 assert!(err.contains("alex"), "got {err}");
202 }
203
204 #[test]
205 fn an_unenrolled_principal_is_rejected_even_from_a_trusted_ca() {
206 let tmp = tempfile::TempDir::new().unwrap();
207 let (ca, ca_pub) = keygen(tmp.path(), "ca");
208 let (_alex_key, alex_pub) = keygen(tmp.path(), "alex");
209 let (mallory_key, _mallory_pub) = keygen(tmp.path(), "mallory");
210 let gov = governance(&alex_pub, "alex", Some((&ca_pub, "mallory")));
211 let cert = mint(
212 &ca,
213 &mallory_key.with_extension("pub"),
214 "claude-a",
215 Some("mallory"),
216 VALID_WINDOW,
217 &[],
218 );
219
220 let err = validate(&cert, &gov, NOW).unwrap_err();
221 assert!(err.contains("mallory"), "got {err}");
222 }
223
224 #[test]
225 fn zero_principals_is_rejected() {
226 let tmp = tempfile::TempDir::new().unwrap();
227 let (ca, ca_pub) = keygen(tmp.path(), "ca");
228 let (person_key, person_pub) = keygen(tmp.path(), "alex");
229 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
230 let cert = mint(
231 &ca,
232 &person_key.with_extension("pub"),
233 "claude-a",
234 None,
235 VALID_WINDOW,
236 &[],
237 );
238
239 let err = validate(&cert, &gov, NOW).unwrap_err();
240 assert!(err.contains("no principal"), "got {err}");
241 }
242
243 #[test]
244 fn two_principals_are_rejected() {
245 let tmp = tempfile::TempDir::new().unwrap();
246 let (ca, ca_pub) = keygen(tmp.path(), "ca");
247 let (person_key, person_pub) = keygen(tmp.path(), "alex");
248 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
249 let cert = mint(
250 &ca,
251 &person_key.with_extension("pub"),
252 "claude-a",
253 Some("alex,bob"),
254 VALID_WINDOW,
255 &[],
256 );
257
258 let err = validate(&cert, &gov, NOW).unwrap_err();
259 assert!(err.contains("2 principals"), "got {err}");
260 }
261
262 #[test]
263 fn a_host_certificate_is_rejected() {
264 let tmp = tempfile::TempDir::new().unwrap();
265 let (ca, ca_pub) = keygen(tmp.path(), "ca");
266 let (person_key, person_pub) = keygen(tmp.path(), "alex");
267 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
268 let cert = mint(
269 &ca,
270 &person_key.with_extension("pub"),
271 "claude-a",
272 Some("alex"),
273 VALID_WINDOW,
274 &["-h"],
275 );
276
277 let err = validate(&cert, &gov, NOW).unwrap_err();
278 assert!(err.contains("user certificate"), "got {err}");
279 }
280
281 #[test]
282 fn an_unknown_critical_option_is_rejected() {
283 let tmp = tempfile::TempDir::new().unwrap();
284 let (ca, ca_pub) = keygen(tmp.path(), "ca");
285 let (person_key, person_pub) = keygen(tmp.path(), "alex");
286 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
287 let cert = mint(
288 &ca,
289 &person_key.with_extension("pub"),
290 "claude-a",
291 Some("alex"),
292 VALID_WINDOW,
293 &["-O", "force-command=/bin/true"],
294 );
295
296 let err = validate(&cert, &gov, NOW).unwrap_err();
297 assert!(err.contains("critical option"), "got {err}");
298 }
299
300 #[test]
301 fn an_expired_certificate_is_rejected() {
302 let tmp = tempfile::TempDir::new().unwrap();
303 let (ca, ca_pub) = keygen(tmp.path(), "ca");
304 let (person_key, person_pub) = keygen(tmp.path(), "alex");
305 let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex")));
306 let cert = mint(
307 &ca,
308 &person_key.with_extension("pub"),
309 "claude-a",
310 Some("alex"),
311 EXPIRED_WINDOW,
312 &[],
313 );
314
315 let err = validate(&cert, &gov, NOW).unwrap_err();
316 assert!(err.contains("did not validate"), "got {err}");
317 }
318 }
src/server/governance/mod.rs
Old New
@@ -73,6 +73,7 @@
73 73
74 pub mod cadir; 74 pub mod cadir;
75 pub mod conf; 75 pub mod conf;
76 pub mod delegate;
76 pub mod hook; 77 pub mod hook;
77 pub mod keydir; 78 pub mod keydir;
78 79