a73x

2d4dd2b6

Certificates authenticate as delegates of the person they name

a73x   2026-08-18 17:08

Commit message
Certificates authenticate as delegates of the person they name

Adds an auth_openssh_certificate handler beside auth_publickey: russh
verifies key possession and the cert's own expiry/signature, then
governance::delegate::validate re-checks both against cadir/keydir so
the regime can revalidate on every command, not just at connection
open. Session identity becomes an AuthIdentity enum (Key or Delegate)
instead of a bare fingerprint string, and regime() drops its parameter
to read self.authenticated directly, dispatching per identity.

Regime::Governed gains a delegate: Option<String> marker (the cert key
ID) so a later task can apply a ceiling on what a delegate may do
without touching this dispatch again.

CaDir::is_empty is removed: it had no production caller, and the one
test that used it now asserts emptiness through fingerprints_for
instead.

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

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 } = &regime { 809 if let Regime::Governed {
810 governance, name, ..
811 } = &regime
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 }