1d5295ea
Validate a certificate into a delegate of an enrolled person
a73x 2026-08-18 16:52
Commit message
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 | ||