2d4dd2b6
Certificates authenticate as delegates of the person they name
a73x 2026-08-18 17:08
Commit message
src/server/governance/cadir.rs
| Old | New | ||
|---|---|---|---|
| @@ -71,10 +71,6 @@ impl CaDir { | |||
| 71 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { | 71 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { |
| 72 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) | 72 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) |
| 73 | } | 73 | } |
| 74 | |||
| 75 | pub fn is_empty(&self) -> bool { | ||
| 76 | self.by_name.is_empty() | ||
| 77 | } | ||
| 78 | } | 74 | } |
| 79 | 75 | ||
| 80 | #[cfg(test)] | 76 | #[cfg(test)] |
| @@ -146,7 +142,7 @@ mod tests { | |||
| 146 | fn non_pub_files_are_ignored() { | 142 | fn non_pub_files_are_ignored() { |
| 147 | let mut cas = CaDir::new(); | 143 | let mut cas = CaDir::new(); |
| 148 | cas.insert("cadir/README", "not a key").unwrap(); | 144 | cas.insert("cadir/README", "not a key").unwrap(); |
| 149 | assert!(cas.is_empty()); | 145 | assert!(cas.fingerprints_for("README").is_empty()); |
| 150 | } | 146 | } |
| 151 | 147 | ||
| 152 | #[test] | 148 | #[test] |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | use std::path::{Path, PathBuf}; | 1 | use std::path::{Path, PathBuf}; |
| 2 | use std::sync::Arc; | 2 | use std::sync::Arc; |
| 3 | 3 | ||
| 4 | use russh::keys::ssh_key::Certificate; | ||
| 4 | use russh::keys::{HashAlg, PublicKey, PublicKeyBase64}; | 5 | use russh::keys::{HashAlg, PublicKey, PublicKeyBase64}; |
| 5 | use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session}; | 6 | use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session}; |
| 6 | use russh::{Channel, ChannelId}; | 7 | use russh::{Channel, ChannelId}; |
| @@ -26,6 +27,12 @@ enum Regime { | |||
| 26 | Governed { | 27 | Governed { |
| 27 | governance: Box<Governance>, | 28 | governance: Box<Governance>, |
| 28 | name: String, | 29 | name: String, |
| 30 | /// `Some(key_id)` when this session is a certificate, acting for | ||
| 31 | /// `name` under the hard-coded collab-refs ceiling. | ||
| 32 | /// | ||
| 33 | /// Not read yet: the ceiling itself lands in a follow-up task. | ||
| 34 | #[allow(dead_code)] | ||
| 35 | delegate: Option<String>, | ||
| 29 | }, | 36 | }, |
| 30 | /// Either the settings repository could not be read, or this connection's | 37 | /// Either the settings repository could not be read, or this connection's |
| 31 | /// key is not enrolled in it. Nothing is permitted. | 38 | /// key is not enrolled in it. Nothing is permitted. |
| @@ -40,10 +47,21 @@ pub struct SshServerConfig { | |||
| 40 | pub max_release_size: u64, | 47 | pub max_release_size: u64, |
| 41 | } | 48 | } |
| 42 | 49 | ||
| 50 | /// Which credential this connection authenticated with. | ||
| 51 | enum AuthIdentity { | ||
| 52 | /// An enrolled key (or authorized_keys, ungoverned): the principal string | ||
| 53 | /// is the fingerprint form ssh_key_principal produces. | ||
| 54 | Key { principal: String }, | ||
| 55 | /// A delegate certificate. Kept whole so the regime can re-validate it | ||
| 56 | /// per request — expiry and cadir/keydir membership are checked on every | ||
| 57 | /// command, not once at connection open. | ||
| 58 | Delegate { certificate: Box<Certificate> }, | ||
| 59 | } | ||
| 60 | |||
| 43 | /// Per-connection SSH session handler. | 61 | /// Per-connection SSH session handler. |
| 44 | pub struct SshHandler { | 62 | pub struct SshHandler { |
| 45 | config: Arc<SshServerConfig>, | 63 | config: Arc<SshServerConfig>, |
| 46 | authenticated_principal: Option<String>, | 64 | authenticated: Option<AuthIdentity>, |
| 47 | /// Sender for forwarding client data (stdin) to the spawned git | 65 | /// Sender for forwarding client data (stdin) to the spawned git |
| 48 | /// subprocess, tagged with the channel that owns it. SSH connections can | 66 | /// subprocess, tagged with the channel that owns it. SSH connections can |
| 49 | /// multiplex channels, so both routing and teardown must match on it: | 67 | /// multiplex channels, so both routing and teardown must match on it: |
| @@ -70,7 +88,7 @@ impl SshHandler { | |||
| 70 | pub fn new(config: Arc<SshServerConfig>) -> Self { | 88 | pub fn new(config: Arc<SshServerConfig>) -> Self { |
| 71 | Self { | 89 | Self { |
| 72 | config, | 90 | config, |
| 73 | authenticated_principal: None, | 91 | authenticated: None, |
| 74 | stdin_tx: None, | 92 | stdin_tx: None, |
| 75 | active_upload: None, | 93 | active_upload: None, |
| 76 | } | 94 | } |
| @@ -82,23 +100,56 @@ impl SshHandler { | |||
| 82 | /// command it issues afterwards is authorized against the config as it | 100 | /// command it issues afterwards is authorized against the config as it |
| 83 | /// stands now, so revoking a key stops the *next* command rather than | 101 | /// stands now, so revoking a key stops the *next* command rather than |
| 84 | /// waiting for the connection to drop. | 102 | /// waiting for the connection to drop. |
| 85 | fn regime(&self, fingerprint: &str) -> Regime { | 103 | fn regime(&self) -> Regime { |
| 86 | match governance::load(&self.config.repos_dir) { | 104 | let Some(identity) = self.authenticated.as_ref() else { |
| 87 | GovernanceState::Absent => Regime::Ungoverned, | 105 | return Regime::Closed; |
| 88 | GovernanceState::Unreadable(reason) => { | 106 | }; |
| 89 | error!("Settings repository is unreadable, closing everything: {reason}"); | 107 | match identity { |
| 90 | Regime::Closed | 108 | AuthIdentity::Key { principal } => match governance::load(&self.config.repos_dir) { |
| 91 | } | 109 | GovernanceState::Absent => Regime::Ungoverned, |
| 92 | GovernanceState::Active(governance) => match governance.name_for(fingerprint) { | 110 | GovernanceState::Unreadable(reason) => { |
| 93 | Some(name) => { | 111 | error!("Settings repository is unreadable, closing everything: {reason}"); |
| 94 | let name = name.to_string(); | ||
| 95 | Regime::Governed { governance, name } | ||
| 96 | } | ||
| 97 | None => { | ||
| 98 | warn!("Key {fingerprint} is not enrolled in keydir/"); | ||
| 99 | Regime::Closed | 112 | Regime::Closed |
| 100 | } | 113 | } |
| 114 | GovernanceState::Active(governance) => match governance.name_for(principal) { | ||
| 115 | Some(name) => { | ||
| 116 | let name = name.to_string(); | ||
| 117 | Regime::Governed { | ||
| 118 | governance, | ||
| 119 | name, | ||
| 120 | delegate: None, | ||
| 121 | } | ||
| 122 | } | ||
| 123 | None => { | ||
| 124 | warn!("Key {principal} is not enrolled in keydir/"); | ||
| 125 | Regime::Closed | ||
| 126 | } | ||
| 127 | }, | ||
| 101 | }, | 128 | }, |
| 129 | AuthIdentity::Delegate { certificate } => { | ||
| 130 | match governance::load(&self.config.repos_dir) { | ||
| 131 | GovernanceState::Active(governance) => { | ||
| 132 | match governance::delegate::validate(certificate, &governance, unix_now()) { | ||
| 133 | Ok(d) => Regime::Governed { | ||
| 134 | governance, | ||
| 135 | name: d.person, | ||
| 136 | delegate: Some(d.key_id), | ||
| 137 | }, | ||
| 138 | Err(reason) => { | ||
| 139 | warn!("Delegate no longer valid, closing: {reason}"); | ||
| 140 | Regime::Closed | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | GovernanceState::Unreadable(reason) => { | ||
| 145 | error!("Settings repository is unreadable, closing everything: {reason}"); | ||
| 146 | Regime::Closed | ||
| 147 | } | ||
| 148 | // Governance turned off since auth: the delegate's whole | ||
| 149 | // basis is gone. | ||
| 150 | GovernanceState::Absent => Regime::Closed, | ||
| 151 | } | ||
| 152 | } | ||
| 102 | } | 153 | } |
| 103 | } | 154 | } |
| 104 | 155 | ||
| @@ -200,16 +251,16 @@ impl SshHandler { | |||
| 200 | Access::Read => entry.policy.allows_read(principal), | 251 | Access::Read => entry.policy.allows_read(principal), |
| 201 | _ => entry.policy.allows_write(principal), | 252 | _ => entry.policy.allows_write(principal), |
| 202 | }, | 253 | }, |
| 203 | Regime::Governed { governance, name } => { | 254 | Regime::Governed { |
| 204 | match governance::repo_key(&self.config.repos_dir, resolved_path) { | 255 | governance, name, .. |
| 205 | Some(key) => { | 256 | } => match governance::repo_key(&self.config.repos_dir, resolved_path) { |
| 206 | let creator = governance::creator_of(resolved_path); | 257 | Some(key) => { |
| 207 | let subject = Subject::with_creator(name, creator.as_deref()); | 258 | let creator = governance::creator_of(resolved_path); |
| 208 | governance.conf.allows_repo(&key, &subject, needed) | 259 | let subject = Subject::with_creator(name, creator.as_deref()); |
| 209 | } | 260 | governance.conf.allows_repo(&key, &subject, needed) |
| 210 | None => false, | ||
| 211 | } | 261 | } |
| 212 | } | 262 | None => false, |
| 263 | }, | ||
| 213 | }; | 264 | }; |
| 214 | if !authorized { | 265 | if !authorized { |
| 215 | warn!( | 266 | warn!( |
| @@ -287,6 +338,15 @@ pub fn ssh_key_principal(public_key: &PublicKey) -> String { | |||
| 287 | format!("key:{}", public_key.fingerprint(HashAlg::Sha256)) | 338 | format!("key:{}", public_key.fingerprint(HashAlg::Sha256)) |
| 288 | } | 339 | } |
| 289 | 340 | ||
| 341 | /// Seconds since the epoch, for certificate validity checks. | ||
| 342 | /// `unwrap_or(0)` fails closed: time-before-epoch validates nothing. | ||
| 343 | fn unix_now() -> u64 { | ||
| 344 | std::time::SystemTime::now() | ||
| 345 | .duration_since(std::time::UNIX_EPOCH) | ||
| 346 | .map(|d| d.as_secs()) | ||
| 347 | .unwrap_or(0) | ||
| 348 | } | ||
| 349 | |||
| 290 | /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`. | 350 | /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`. |
| 291 | /// Returns (command, repo_path) or None if the command is not allowed. | 351 | /// Returns (command, repo_path) or None if the command is not allowed. |
| 292 | pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { | 352 | pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { |
| @@ -565,7 +625,7 @@ impl Handler for SshHandler { | |||
| 565 | "Public key auth accepted for key type {} ({})", | 625 | "Public key auth accepted for key type {} ({})", |
| 566 | key_type, principal | 626 | key_type, principal |
| 567 | ); | 627 | ); |
| 568 | self.authenticated_principal = Some(principal); | 628 | self.authenticated = Some(AuthIdentity::Key { principal }); |
| 569 | Ok(Auth::Accept) | 629 | Ok(Auth::Accept) |
| 570 | } else { | 630 | } else { |
| 571 | debug!("Public key auth rejected for key type {}", key_type); | 631 | debug!("Public key auth rejected for key type {}", key_type); |
| @@ -573,13 +633,40 @@ impl Handler for SshHandler { | |||
| 573 | } | 633 | } |
| 574 | } | 634 | } |
| 575 | 635 | ||
| 636 | async fn auth_openssh_certificate( | ||
| 637 | &mut self, | ||
| 638 | _user: &str, | ||
| 639 | certificate: &Certificate, | ||
| 640 | ) -> Result<Auth, Self::Error> { | ||
| 641 | let GovernanceState::Active(governance) = governance::load(&self.config.repos_dir) else { | ||
| 642 | debug!("Certificate auth rejected: server is not governed"); | ||
| 643 | return Ok(Auth::reject()); | ||
| 644 | }; | ||
| 645 | match governance::delegate::validate(certificate, &governance, unix_now()) { | ||
| 646 | Ok(delegate) => { | ||
| 647 | info!( | ||
| 648 | "Certificate auth accepted: {} (via {})", | ||
| 649 | delegate.person, delegate.key_id | ||
| 650 | ); | ||
| 651 | self.authenticated = Some(AuthIdentity::Delegate { | ||
| 652 | certificate: Box::new(certificate.clone()), | ||
| 653 | }); | ||
| 654 | Ok(Auth::Accept) | ||
| 655 | } | ||
| 656 | Err(reason) => { | ||
| 657 | debug!("Certificate auth rejected: {reason}"); | ||
| 658 | Ok(Auth::reject()) | ||
| 659 | } | ||
| 660 | } | ||
| 661 | } | ||
| 662 | |||
| 576 | async fn channel_open_session( | 663 | async fn channel_open_session( |
| 577 | &mut self, | 664 | &mut self, |
| 578 | channel: Channel<Msg>, | 665 | channel: Channel<Msg>, |
| 579 | reply: ChannelOpenHandle, | 666 | reply: ChannelOpenHandle, |
| 580 | _session: &mut Session, | 667 | _session: &mut Session, |
| 581 | ) -> Result<(), Self::Error> { | 668 | ) -> Result<(), Self::Error> { |
| 582 | if self.authenticated_principal.is_some() { | 669 | if self.authenticated.is_some() { |
| 583 | debug!("Session channel opened: {:?}", channel.id()); | 670 | debug!("Session channel opened: {:?}", channel.id()); |
| 584 | reply.accept().await; | 671 | reply.accept().await; |
| 585 | } else { | 672 | } else { |
| @@ -628,8 +715,11 @@ impl Handler for SshHandler { | |||
| 628 | } | 715 | } |
| 629 | }; | 716 | }; |
| 630 | 717 | ||
| 631 | let principal = match self.authenticated_principal.clone() { | 718 | let principal = match self.authenticated.as_ref() { |
| 632 | Some(principal) => principal, | 719 | Some(AuthIdentity::Key { principal }) => principal.clone(), |
| 720 | Some(AuthIdentity::Delegate { certificate }) => { | ||
| 721 | format!("delegate:{}", certificate.key_id()) | ||
| 722 | } | ||
| 633 | None => { | 723 | None => { |
| 634 | warn!("Rejected exec request: not authenticated"); | 724 | warn!("Rejected exec request: not authenticated"); |
| 635 | return reply_and_close(session, channel, "", 1); | 725 | return reply_and_close(session, channel, "", 1); |
| @@ -644,7 +734,7 @@ impl Handler for SshHandler { | |||
| 644 | } | 734 | } |
| 645 | }; | 735 | }; |
| 646 | 736 | ||
| 647 | let regime = self.regime(&principal); | 737 | let regime = self.regime(); |
| 648 | 738 | ||
| 649 | let git_cmd = match exec_cmd { | 739 | let git_cmd = match exec_cmd { |
| 650 | ExecCommand::Git { cmd, .. } => cmd, | 740 | ExecCommand::Git { cmd, .. } => cmd, |
| @@ -691,7 +781,12 @@ impl Handler for SshHandler { | |||
| 691 | Access::Read => entry.policy.allows_read(&principal), | 781 | Access::Read => entry.policy.allows_read(&principal), |
| 692 | _ => entry.policy.allows_write(&principal), | 782 | _ => entry.policy.allows_write(&principal), |
| 693 | }, | 783 | }, |
| 694 | (Regime::Governed { governance, name }, Some(key)) => { | 784 | ( |
| 785 | Regime::Governed { | ||
| 786 | governance, name, .. | ||
| 787 | }, | ||
| 788 | Some(key), | ||
| 789 | ) => { | ||
| 695 | let creator = governance::creator_of(&resolved_path); | 790 | let creator = governance::creator_of(&resolved_path); |
| 696 | let subject = Subject::with_creator(name, creator.as_deref()); | 791 | let subject = Subject::with_creator(name, creator.as_deref()); |
| 697 | governance.conf.allows_repo(key, &subject, needed) | 792 | governance.conf.allows_repo(key, &subject, needed) |
| @@ -711,7 +806,10 @@ impl Handler for SshHandler { | |||
| 711 | // Auto-creation is what makes wild repos work without a central | 806 | // Auto-creation is what makes wild repos work without a central |
| 712 | // allocator, so under governance it is a permission of its own: | 807 | // allocator, so under governance it is a permission of its own: |
| 713 | // `C` on a pattern the requested name matches. | 808 | // `C` on a pattern the requested name matches. |
| 714 | if let Regime::Governed { governance, name } = ®ime { | 809 | if let Regime::Governed { |
| 810 | governance, name, .. | ||
| 811 | } = ®ime | ||
| 812 | { | ||
| 715 | let allowed = repo_key.as_deref().is_some_and(|key| { | 813 | let allowed = repo_key.as_deref().is_some_and(|key| { |
| 716 | governance | 814 | governance |
| 717 | .conf | 815 | .conf |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -730,11 +730,7 @@ impl TestRepo { | |||
| 730 | .iter() | 730 | .iter() |
| 731 | .map(|a| format!("'{}'", a.replace('\'', r"'\''"))) | 731 | .map(|a| format!("'{}'", a.replace('\'', r"'\''"))) |
| 732 | .collect(); | 732 | .collect(); |
| 733 | let script = format!( | 733 | let script = format!("{} {}", env!("CARGO_BIN_EXE_git-collab"), quoted.join(" ")); |
| 734 | "{} {}", | ||
| 735 | env!("CARGO_BIN_EXE_git-collab"), | ||
| 736 | quoted.join(" ") | ||
| 737 | ); | ||
| 738 | let mut command = Command::new("script"); | 734 | let mut command = Command::new("script"); |
| 739 | self.apply_env(&mut command); | 735 | self.apply_env(&mut command); |
| 740 | command.env("EDITOR", editor); | 736 | command.env("EDITOR", editor); |
| @@ -858,7 +854,9 @@ impl TestRepo { | |||
| 858 | if !exited { | 854 | if !exited { |
| 859 | let _ = child.kill(); | 855 | let _ = child.kill(); |
| 860 | } | 856 | } |
| 861 | let status = child.wait().expect("failed to collect dashboard exit status"); | 857 | let status = child |
| 858 | .wait() | ||
| 859 | .expect("failed to collect dashboard exit status"); | ||
| 862 | Output { | 860 | Output { |
| 863 | status, | 861 | status, |
| 864 | stdout: out.finish(), | 862 | stdout: out.finish(), |
| @@ -940,7 +938,9 @@ impl TestRepo { | |||
| 940 | if !exited { | 938 | if !exited { |
| 941 | let _ = child.kill(); | 939 | let _ = child.kill(); |
| 942 | } | 940 | } |
| 943 | let status = child.wait().expect("failed to collect dashboard exit status"); | 941 | let status = child |
| 942 | .wait() | ||
| 943 | .expect("failed to collect dashboard exit status"); | ||
| 944 | Output { | 944 | Output { |
| 945 | status, | 945 | status, |
| 946 | stdout: out.finish(), | 946 | stdout: out.finish(), |
| @@ -1219,6 +1219,66 @@ impl ServerHarness { | |||
| 1219 | key_path | 1219 | key_path |
| 1220 | } | 1220 | } |
| 1221 | 1221 | ||
| 1222 | /// Generate a *named* CA keypair under the harness root, or return the | ||
| 1223 | /// one already generated for that name. Mirrors `named_key`, but under | ||
| 1224 | /// `cas/` rather than `keys/` — these are enrolled through `cadir/`, | ||
| 1225 | /// never `keydir/`. | ||
| 1226 | pub fn delegate_ca(&self, name: &str) -> PathBuf { | ||
| 1227 | let dir = self.root.path().join("cas"); | ||
| 1228 | std::fs::create_dir_all(&dir).unwrap(); | ||
| 1229 | let key_path = dir.join(name); | ||
| 1230 | if !key_path.exists() { | ||
| 1231 | let output = Command::new("ssh-keygen") | ||
| 1232 | .args([ | ||
| 1233 | "-t", | ||
| 1234 | "ed25519", | ||
| 1235 | "-N", | ||
| 1236 | "", | ||
| 1237 | "-q", | ||
| 1238 | "-C", | ||
| 1239 | &format!("{name}@test"), | ||
| 1240 | "-f", | ||
| 1241 | key_path.to_str().unwrap(), | ||
| 1242 | ]) | ||
| 1243 | .output() | ||
| 1244 | .expect("failed to run ssh-keygen"); | ||
| 1245 | assert!( | ||
| 1246 | output.status.success(), | ||
| 1247 | "ssh-keygen failed: {}", | ||
| 1248 | String::from_utf8_lossy(&output.stderr) | ||
| 1249 | ); | ||
| 1250 | } | ||
| 1251 | key_path | ||
| 1252 | } | ||
| 1253 | |||
| 1254 | /// Mint a certificate with ssh-keygen — the external oracle for what a | ||
| 1255 | /// valid OpenSSH cert looks like. Returns the `<key>-cert.pub` path. | ||
| 1256 | /// `validity` is ssh-keygen's -V syntax, e.g. "-1m:+30m". | ||
| 1257 | pub fn mint_cert( | ||
| 1258 | &self, | ||
| 1259 | ca: &Path, | ||
| 1260 | key: &Path, | ||
| 1261 | key_id: &str, | ||
| 1262 | principals: &str, | ||
| 1263 | validity: &str, | ||
| 1264 | ) -> PathBuf { | ||
| 1265 | let cert = PathBuf::from(format!("{}-cert.pub", key.display())); | ||
| 1266 | let _ = std::fs::remove_file(&cert); // ssh-keygen refuses to overwrite | ||
| 1267 | let mut cmd = Command::new("ssh-keygen"); | ||
| 1268 | cmd.arg("-s").arg(ca).args(["-I", key_id, "-V", validity]); | ||
| 1269 | if !principals.is_empty() { | ||
| 1270 | cmd.args(["-n", principals]); | ||
| 1271 | } | ||
| 1272 | cmd.arg(key.with_extension("pub")); | ||
| 1273 | let output = cmd.output().expect("failed to run ssh-keygen -s"); | ||
| 1274 | assert!( | ||
| 1275 | output.status.success(), | ||
| 1276 | "ssh-keygen -s failed: {}", | ||
| 1277 | String::from_utf8_lossy(&output.stderr) | ||
| 1278 | ); | ||
| 1279 | cert | ||
| 1280 | } | ||
| 1281 | |||
| 1222 | /// The `authorized_keys` file the running server authenticates against | 1282 | /// The `authorized_keys` file the running server authenticates against |
| 1223 | /// while it is ungoverned, and that `setup` reads to decide who to enrol. | 1283 | /// while it is ungoverned, and that `setup` reads to decide who to enrol. |
| 1224 | pub fn authorized_keys_path(&self) -> PathBuf { | 1284 | pub fn authorized_keys_path(&self) -> PathBuf { |
| @@ -1274,6 +1334,18 @@ impl ServerHarness { | |||
| 1274 | /// name of a `named_key`, so a test can put one key at two paths and see | 1334 | /// name of a `named_key`, so a test can put one key at two paths and see |
| 1275 | /// them collapse to one identity. | 1335 | /// them collapse to one identity. |
| 1276 | pub fn stage_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { | 1336 | pub fn stage_settings(&self, access_conf: &str, keys: &[(&str, &str)]) { |
| 1337 | self.stage_settings_with_cas(access_conf, keys, &[]); | ||
| 1338 | } | ||
| 1339 | |||
| 1340 | /// Like `stage_settings`, but also writes `cadir/` entries. `cas` maps a | ||
| 1341 | /// path *within* `cadir/` (e.g. `mint/alex.pub`) to the name of a | ||
| 1342 | /// `delegate_ca`, exactly as `keys` maps into `keydir/`. | ||
| 1343 | pub fn stage_settings_with_cas( | ||
| 1344 | &self, | ||
| 1345 | access_conf: &str, | ||
| 1346 | keys: &[(&str, &str)], | ||
| 1347 | cas: &[(&str, &str)], | ||
| 1348 | ) { | ||
| 1277 | self.ensure_settings_repos(); | 1349 | self.ensure_settings_repos(); |
| 1278 | let work = self.settings_work(); | 1350 | let work = self.settings_work(); |
| 1279 | 1351 | ||
| @@ -1289,6 +1361,14 @@ impl ServerHarness { | |||
| 1289 | std::fs::write(dest, pubkey).unwrap(); | 1361 | std::fs::write(dest, pubkey).unwrap(); |
| 1290 | } | 1362 | } |
| 1291 | 1363 | ||
| 1364 | for (rel, ca_name) in cas { | ||
| 1365 | let dest = work.join("cadir").join(rel); | ||
| 1366 | std::fs::create_dir_all(dest.parent().unwrap()).unwrap(); | ||
| 1367 | let pubkey = | ||
| 1368 | std::fs::read_to_string(self.delegate_ca(ca_name).with_extension("pub")).unwrap(); | ||
| 1369 | std::fs::write(dest, pubkey).unwrap(); | ||
| 1370 | } | ||
| 1371 | |||
| 1292 | git(&work, &["add", "-A"]); | 1372 | git(&work, &["add", "-A"]); |
| 1293 | git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]); | 1373 | git(&work, &["commit", "-q", "--allow-empty", "-m", "settings"]); |
| 1294 | } | 1374 | } |
| @@ -1304,6 +1384,20 @@ impl ServerHarness { | |||
| 1304 | ); | 1384 | ); |
| 1305 | } | 1385 | } |
| 1306 | 1386 | ||
| 1387 | /// Like `bootstrap_settings`, but also enrolls `cadir/` entries. | ||
| 1388 | pub fn bootstrap_settings_with_cas( | ||
| 1389 | &self, | ||
| 1390 | access_conf: &str, | ||
| 1391 | keys: &[(&str, &str)], | ||
| 1392 | cas: &[(&str, &str)], | ||
| 1393 | ) { | ||
| 1394 | self.stage_settings_with_cas(access_conf, keys, cas); | ||
| 1395 | git( | ||
| 1396 | &self.settings_work(), | ||
| 1397 | &["push", "-q", "-f", "origin", "main"], | ||
| 1398 | ); | ||
| 1399 | } | ||
| 1400 | |||
| 1307 | /// Overwrite `conf/access.conf` with arbitrary text and commit it, so a | 1401 | /// Overwrite `conf/access.conf` with arbitrary text and commit it, so a |
| 1308 | /// test can stage a config that does not parse. | 1402 | /// test can stage a config that does not parse. |
| 1309 | pub fn stage_raw_access_conf(&self, content: &str) { | 1403 | pub fn stage_raw_access_conf(&self, content: &str) { |
| @@ -1409,6 +1503,39 @@ impl ServerHarness { | |||
| 1409 | .expect("failed to run git ls-remote") | 1503 | .expect("failed to run git ls-remote") |
| 1410 | } | 1504 | } |
| 1411 | 1505 | ||
| 1506 | /// Like `ssh_push_from`, but authenticating with a delegate certificate | ||
| 1507 | /// rather than the key's own enrollment. | ||
| 1508 | pub fn ssh_push_from_cert( | ||
| 1509 | &self, | ||
| 1510 | dir: &Path, | ||
| 1511 | key: &Path, | ||
| 1512 | cert: &Path, | ||
| 1513 | repo: &str, | ||
| 1514 | refspec: &str, | ||
| 1515 | ) -> Output { | ||
| 1516 | let url = self.ssh_url_for(repo); | ||
| 1517 | Command::new("git") | ||
| 1518 | .args(["push", &url, refspec]) | ||
| 1519 | .env("GIT_SSH_COMMAND", ssh_command_for_cert(key, cert)) | ||
| 1520 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 1521 | .current_dir(dir) | ||
| 1522 | .output() | ||
| 1523 | .expect("failed to run git push") | ||
| 1524 | } | ||
| 1525 | |||
| 1526 | /// Like `ssh_fetch`, but authenticating with a delegate certificate | ||
| 1527 | /// rather than the key's own enrollment. | ||
| 1528 | pub fn ssh_fetch_cert(&self, dir: &Path, key: &Path, cert: &Path, repo: &str) -> Output { | ||
| 1529 | let url = self.ssh_url_for(repo); | ||
| 1530 | Command::new("git") | ||
| 1531 | .args(["ls-remote", &url]) | ||
| 1532 | .env("GIT_SSH_COMMAND", ssh_command_for_cert(key, cert)) | ||
| 1533 | .env("GIT_TERMINAL_PROMPT", "0") | ||
| 1534 | .current_dir(dir) | ||
| 1535 | .output() | ||
| 1536 | .expect("failed to run git ls-remote") | ||
| 1537 | } | ||
| 1538 | |||
| 1412 | /// The ssh client options needed to reach this test server, as a single | 1539 | /// The ssh client options needed to reach this test server, as a single |
| 1413 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. | 1540 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. |
| 1414 | pub fn ssh_command_string(&self) -> String { | 1541 | pub fn ssh_command_string(&self) -> String { |
| @@ -1787,6 +1914,16 @@ fn ssh_command_for(key: &Path) -> String { | |||
| 1787 | ) | 1914 | ) |
| 1788 | } | 1915 | } |
| 1789 | 1916 | ||
| 1917 | /// Like `ssh_command_for`, but presenting `cert` as an OpenSSH certificate | ||
| 1918 | /// for `key`. | ||
| 1919 | fn ssh_command_for_cert(key: &Path, cert: &Path) -> String { | ||
| 1920 | format!( | ||
| 1921 | "{} -o CertificateFile={}", | ||
| 1922 | ssh_command_for(key), | ||
| 1923 | cert.display() | ||
| 1924 | ) | ||
| 1925 | } | ||
| 1926 | |||
| 1790 | fn pick_loopback_addr() -> SocketAddr { | 1927 | fn pick_loopback_addr() -> SocketAddr { |
| 1791 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); | 1928 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
| 1792 | let addr = listener.local_addr().unwrap(); | 1929 | let addr = listener.local_addr().unwrap(); |
tests/delegate_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,96 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::process::Output; | ||
| 4 | |||
| 5 | use common::ServerHarness; | ||
| 6 | |||
| 7 | fn stderr(output: &Output) -> String { | ||
| 8 | String::from_utf8_lossy(&output.stderr).into_owned() | ||
| 9 | } | ||
| 10 | |||
| 11 | /// Rules never mention delegates: the person holds the grants, the cert | ||
| 12 | /// borrows them. | ||
| 13 | fn access_conf(repo: &str) -> String { | ||
| 14 | format!("repo settings\n RW+ = alex\n\nrepo {repo}\n RW+ = alex\n RW+ = bob\n") | ||
| 15 | } | ||
| 16 | |||
| 17 | /// A certificate from an enrolled CA, naming an enrolled person, can read | ||
| 18 | /// what the person reads. | ||
| 19 | #[test] | ||
| 20 | fn a_delegate_certificate_authenticates_and_fetches() { | ||
| 21 | let harness = ServerHarness::new("delegate-fetch"); | ||
| 22 | harness.push_head(); | ||
| 23 | harness.bootstrap_settings_with_cas( | ||
| 24 | &access_conf(harness.repo_name()), | ||
| 25 | &[("alex.pub", "alex")], | ||
| 26 | &[("mint/alex.pub", "mint")], | ||
| 27 | ); | ||
| 28 | |||
| 29 | // The delegate's own key is enrolled NOWHERE — that is the point. | ||
| 30 | let agent_key = harness.named_key("agent-key"); | ||
| 31 | let ca = harness.delegate_ca("mint"); | ||
| 32 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 33 | |||
| 34 | let out = harness.ssh_fetch_cert( | ||
| 35 | harness.work_repo().dir.path(), | ||
| 36 | &agent_key, | ||
| 37 | &cert, | ||
| 38 | harness.repo_name(), | ||
| 39 | ); | ||
| 40 | assert!( | ||
| 41 | out.status.success(), | ||
| 42 | "delegate fetch failed: {}", | ||
| 43 | stderr(&out) | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | /// The same certificate on an ungoverned server is nothing: there is no | ||
| 48 | /// roster to tie its principal to. | ||
| 49 | #[test] | ||
| 50 | fn a_certificate_is_rejected_on_an_ungoverned_server() { | ||
| 51 | let harness = ServerHarness::new("delegate-ungoverned"); | ||
| 52 | let _ = harness.ssh_client_key(); // authorized_keys exists, server ungoverned | ||
| 53 | harness.push_head(); | ||
| 54 | |||
| 55 | let agent_key = harness.named_key("agent-key"); | ||
| 56 | let ca = harness.delegate_ca("mint"); | ||
| 57 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 58 | |||
| 59 | let out = harness.ssh_fetch_cert( | ||
| 60 | harness.work_repo().dir.path(), | ||
| 61 | &agent_key, | ||
| 62 | &cert, | ||
| 63 | harness.repo_name(), | ||
| 64 | ); | ||
| 65 | assert!( | ||
| 66 | !out.status.success(), | ||
| 67 | "an ungoverned server accepted a certificate" | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | /// A cert whose CA is enrolled for someone else cannot act as this person. | ||
| 72 | #[test] | ||
| 73 | fn a_ca_enrolled_for_another_name_is_rejected() { | ||
| 74 | let harness = ServerHarness::new("delegate-wrong-ca"); | ||
| 75 | harness.push_head(); | ||
| 76 | harness.bootstrap_settings_with_cas( | ||
| 77 | &access_conf(harness.repo_name()), | ||
| 78 | &[("alex.pub", "alex"), ("bob.pub", "bob")], | ||
| 79 | &[("mint/bob.pub", "mint")], // mint may act for bob, NOT alex | ||
| 80 | ); | ||
| 81 | |||
| 82 | let agent_key = harness.named_key("agent-key"); | ||
| 83 | let ca = harness.delegate_ca("mint"); | ||
| 84 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 85 | |||
| 86 | let out = harness.ssh_fetch_cert( | ||
| 87 | harness.work_repo().dir.path(), | ||
| 88 | &agent_key, | ||
| 89 | &cert, | ||
| 90 | harness.repo_name(), | ||
| 91 | ); | ||
| 92 | assert!( | ||
| 93 | !out.status.success(), | ||
| 94 | "a CA enrolled for bob minted a working delegate of alex" | ||
| 95 | ); | ||
| 96 | } | ||