f0ec3c92
Fix the four Important findings from the whole-branch delegate review
a73x 2026-08-18 18:17
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -477,8 +477,13 @@ The cert's principal must name an enrolled person and its CA must be enrolled | |||
| 477 | *for that name* — `cadir/` lends identity, it never creates it. `access.conf` | 477 | *for that name* — `cadir/` lends identity, it never creates it. `access.conf` |
| 478 | never mentions delegates — the person holds the grants and the certificate | 478 | never mentions delegates — the person holds the grants and the certificate |
| 479 | borrows them, clipped to `refs/collab/*`. No rule can widen that clip: none | 479 | borrows them, clipped to `refs/collab/*`. No rule can widen that clip: none |
| 480 | grants a certificate a branch, a release, or a repository creation. The same | 480 | grants a certificate a branch, a release, or a repository creation. The clip |
| 481 | CA key enrolled under two names is allowed (unlike `keydir/`, where one key | 481 | is namespace-scoped, not operation-scoped, though: inside `refs/collab/*` a |
| 482 | delegate inherits whatever the person holds there, rewind and delete | ||
| 483 | included, so `RW+` on a repo lends every delegate of that person the power to | ||
| 484 | rewind or delete collab refs — destroying issue and patch history — where | ||
| 485 | `RW` keeps that out of reach. The same CA key enrolled under two names is | ||
| 486 | allowed (unlike `keydir/`, where one key | ||
| 482 | under two names is an authorization coin-flip): a certificate names its | 487 | under two names is an authorization coin-flip): a certificate names its |
| 483 | principal, so the lookup runs the other way, and a shared CA is two explicit | 488 | principal, so the lookup runs the other way, and a shared CA is two explicit |
| 484 | opt-ins. | 489 | opt-ins. |
src/server/governance/delegate.rs
| Old | New | ||
|---|---|---|---|
| @@ -55,10 +55,26 @@ pub fn validate( | |||
| 55 | cert.validate_at(unix_now, fingerprints.iter()) | 55 | cert.validate_at(unix_now, fingerprints.iter()) |
| 56 | .map_err(|e| format!("certificate did not validate for {person}: {e}"))?; | 56 | .map_err(|e| format!("certificate did not validate for {person}: {e}"))?; |
| 57 | 57 | ||
| 58 | Ok(Delegate { | 58 | // key_id is the CA's choice, not ours, and it reaches a client-visible |
| 59 | person, | 59 | // hook string, a child process env var, and the log — all unescaped. An |
| 60 | key_id: cert.key_id().to_string(), | 60 | // OpenSSH cert places no limit on it (ssh-keygen -I takes any string, |
| 61 | }) | 61 | // newlines included), so refuse anything that isn't a short, printable |
| 62 | // ASCII token before it goes anywhere. | ||
| 63 | let key_id = cert.key_id().to_string(); | ||
| 64 | if key_id.is_empty() { | ||
| 65 | return Err("certificate key ID is empty".to_string()); | ||
| 66 | } | ||
| 67 | if key_id.len() > 64 { | ||
| 68 | return Err(format!( | ||
| 69 | "certificate key ID is {} bytes, longer than 64", | ||
| 70 | key_id.len() | ||
| 71 | )); | ||
| 72 | } | ||
| 73 | if !key_id.bytes().all(|b| (0x20..=0x7E).contains(&b)) { | ||
| 74 | return Err("certificate key ID contains a character outside printable ASCII".to_string()); | ||
| 75 | } | ||
| 76 | |||
| 77 | Ok(Delegate { person, key_id }) | ||
| 62 | } | 78 | } |
| 63 | 79 | ||
| 64 | #[cfg(test)] | 80 | #[cfg(test)] |
| @@ -315,4 +331,66 @@ mod tests { | |||
| 315 | let err = validate(&cert, &gov, NOW).unwrap_err(); | 331 | let err = validate(&cert, &gov, NOW).unwrap_err(); |
| 316 | assert!(err.contains("did not validate"), "got {err}"); | 332 | assert!(err.contains("did not validate"), "got {err}"); |
| 317 | } | 333 | } |
| 334 | |||
| 335 | #[test] | ||
| 336 | fn an_empty_key_id_is_rejected() { | ||
| 337 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 338 | let (ca, ca_pub) = keygen(tmp.path(), "ca"); | ||
| 339 | let (person_key, person_pub) = keygen(tmp.path(), "alex"); | ||
| 340 | let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex"))); | ||
| 341 | let cert = mint( | ||
| 342 | &ca, | ||
| 343 | &person_key.with_extension("pub"), | ||
| 344 | "", | ||
| 345 | Some("alex"), | ||
| 346 | VALID_WINDOW, | ||
| 347 | &[], | ||
| 348 | ); | ||
| 349 | |||
| 350 | let err = validate(&cert, &gov, NOW).unwrap_err(); | ||
| 351 | assert!(err.contains("key ID"), "got {err}"); | ||
| 352 | } | ||
| 353 | |||
| 354 | #[test] | ||
| 355 | fn a_key_id_over_64_bytes_is_rejected() { | ||
| 356 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 357 | let (ca, ca_pub) = keygen(tmp.path(), "ca"); | ||
| 358 | let (person_key, person_pub) = keygen(tmp.path(), "alex"); | ||
| 359 | let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex"))); | ||
| 360 | let long_id = "x".repeat(65); | ||
| 361 | let cert = mint( | ||
| 362 | &ca, | ||
| 363 | &person_key.with_extension("pub"), | ||
| 364 | &long_id, | ||
| 365 | Some("alex"), | ||
| 366 | VALID_WINDOW, | ||
| 367 | &[], | ||
| 368 | ); | ||
| 369 | |||
| 370 | let err = validate(&cert, &gov, NOW).unwrap_err(); | ||
| 371 | assert!(err.contains("key ID"), "got {err}"); | ||
| 372 | } | ||
| 373 | |||
| 374 | #[test] | ||
| 375 | fn a_key_id_containing_a_newline_is_rejected() { | ||
| 376 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 377 | let (ca, ca_pub) = keygen(tmp.path(), "ca"); | ||
| 378 | let (person_key, person_pub) = keygen(tmp.path(), "alex"); | ||
| 379 | let gov = governance(&person_pub, "alex", Some((&ca_pub, "alex"))); | ||
| 380 | // ssh-keygen -I takes any string, newline included — no shell is | ||
| 381 | // involved (it's a single argv element), so this is the external | ||
| 382 | // oracle for "a hostile CA can put a newline in a key ID", not | ||
| 383 | // something our own test setup invented. | ||
| 384 | let cert = mint( | ||
| 385 | &ca, | ||
| 386 | &person_key.with_extension("pub"), | ||
| 387 | "evil\nfake-line", | ||
| 388 | Some("alex"), | ||
| 389 | VALID_WINDOW, | ||
| 390 | &[], | ||
| 391 | ); | ||
| 392 | |||
| 393 | let err = validate(&cert, &gov, NOW).unwrap_err(); | ||
| 394 | assert!(err.contains("key ID"), "got {err}"); | ||
| 395 | } | ||
| 318 | } | 396 | } |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -108,7 +108,13 @@ enum Command { | |||
| 108 | 108 | ||
| 109 | #[tokio::main] | 109 | #[tokio::main] |
| 110 | async fn main() { | 110 | async fn main() { |
| 111 | tracing_subscriber::fmt::init(); | 111 | // stderr, not the default stdout: this server's stdout is otherwise free |
| 112 | // for a future machine-readable use, and callers already expect | ||
| 113 | // diagnostics on stderr (the "server exited before becoming ready" | ||
| 114 | // failure path reads it). | ||
| 115 | tracing_subscriber::fmt() | ||
| 116 | .with_writer(std::io::stderr) | ||
| 117 | .init(); | ||
| 112 | 118 | ||
| 113 | let args = Args::parse(); | 119 | let args = Args::parse(); |
| 114 | 120 | ||
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -748,6 +748,20 @@ impl Handler for SshHandler { | |||
| 748 | 748 | ||
| 749 | let regime = self.regime(); | 749 | let regime = self.regime(); |
| 750 | 750 | ||
| 751 | // The regime is what proves a delegate cert's principal is real and | ||
| 752 | // still enrolled, so only from here on is there a person to name. | ||
| 753 | // Swap the bare-key-ID fallback above for the same "<person> (via | ||
| 754 | // <key-id>)" form the connection-level auth log already uses, so a | ||
| 755 | // delegate's exec/release log lines read the same way. | ||
| 756 | let principal = match ®ime { | ||
| 757 | Regime::Governed { | ||
| 758 | name, | ||
| 759 | delegate: Some(key_id), | ||
| 760 | .. | ||
| 761 | } => format!("{name} (via {key_id})"), | ||
| 762 | _ => principal, | ||
| 763 | }; | ||
| 764 | |||
| 751 | let git_cmd = match exec_cmd { | 765 | let git_cmd = match exec_cmd { |
| 752 | ExecCommand::Git { cmd, .. } => cmd, | 766 | ExecCommand::Git { cmd, .. } => cmd, |
| 753 | ExecCommand::Release(rel) => { | 767 | ExecCommand::Release(rel) => { |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1092,6 +1092,11 @@ impl ServerHarness { | |||
| 1092 | 1092 | ||
| 1093 | let mut server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) | 1093 | let mut server = Command::new(env!("CARGO_BIN_EXE_git-collab-server")) |
| 1094 | .args(["--config", config_path.to_str().unwrap()]) | 1094 | .args(["--config", config_path.to_str().unwrap()]) |
| 1095 | // The server's default level is already INFO, so this is a | ||
| 1096 | // no-op today — it's here so a test asserting on | ||
| 1097 | // `server_log()` content keeps working if that default ever | ||
| 1098 | // tightens. | ||
| 1099 | .env("RUST_LOG", "info") | ||
| 1095 | .stdout(Stdio::piped()) | 1100 | .stdout(Stdio::piped()) |
| 1096 | .stderr(Stdio::piped()) | 1101 | .stderr(Stdio::piped()) |
| 1097 | .spawn() | 1102 | .spawn() |
| @@ -1578,25 +1583,56 @@ impl ServerHarness { | |||
| 1578 | /// than the `authorized_keys` one — the only kind that authenticates on a | 1583 | /// than the `authorized_keys` one — the only kind that authenticates on a |
| 1579 | /// governed server. | 1584 | /// governed server. |
| 1580 | pub fn ssh_exec_as(&self, key: &Path, remote_cmd: &str, stdin: &[u8]) -> Output { | 1585 | pub fn ssh_exec_as(&self, key: &Path, remote_cmd: &str, stdin: &[u8]) -> Output { |
| 1586 | self.ssh_exec_with_identity(key, &[], remote_cmd, stdin) | ||
| 1587 | } | ||
| 1588 | |||
| 1589 | /// Like `ssh_exec_as`, but authenticating with a delegate certificate | ||
| 1590 | /// rather than the key's own enrollment. | ||
| 1591 | pub fn ssh_exec_as_cert( | ||
| 1592 | &self, | ||
| 1593 | key: &Path, | ||
| 1594 | cert: &Path, | ||
| 1595 | remote_cmd: &str, | ||
| 1596 | stdin: &[u8], | ||
| 1597 | ) -> Output { | ||
| 1598 | let cert_flag = format!("CertificateFile={}", cert.display()); | ||
| 1599 | self.ssh_exec_with_identity(key, &["-o", &cert_flag], remote_cmd, stdin) | ||
| 1600 | } | ||
| 1601 | |||
| 1602 | /// Shared plumbing for `ssh_exec_as`/`ssh_exec_as_cert`: the base ssh | ||
| 1603 | /// options common to both, plus whatever `extra_args` the caller needs | ||
| 1604 | /// for identity (e.g. a certificate file). See `ssh_exec_with_stdin` for | ||
| 1605 | /// why stdin is written from a separate thread and a watchdog is needed. | ||
| 1606 | fn ssh_exec_with_identity( | ||
| 1607 | &self, | ||
| 1608 | key: &Path, | ||
| 1609 | extra_args: &[&str], | ||
| 1610 | remote_cmd: &str, | ||
| 1611 | stdin: &[u8], | ||
| 1612 | ) -> Output { | ||
| 1613 | let port = self.ssh_addr.port().to_string(); | ||
| 1614 | let mut args = vec![ | ||
| 1615 | "-p", | ||
| 1616 | port.as_str(), | ||
| 1617 | "-i", | ||
| 1618 | key.to_str().unwrap(), | ||
| 1619 | "-o", | ||
| 1620 | "StrictHostKeyChecking=no", | ||
| 1621 | "-o", | ||
| 1622 | "UserKnownHostsFile=/dev/null", | ||
| 1623 | "-o", | ||
| 1624 | "IdentitiesOnly=yes", | ||
| 1625 | "-o", | ||
| 1626 | "BatchMode=yes", | ||
| 1627 | "-o", | ||
| 1628 | "ConnectTimeout=5", | ||
| 1629 | ]; | ||
| 1630 | args.extend_from_slice(extra_args); | ||
| 1631 | args.push("git@127.0.0.1"); | ||
| 1632 | args.push(remote_cmd); | ||
| 1633 | |||
| 1581 | let mut child = Command::new("ssh") | 1634 | let mut child = Command::new("ssh") |
| 1582 | .args([ | 1635 | .args(args) |
| 1583 | "-p", | ||
| 1584 | &self.ssh_addr.port().to_string(), | ||
| 1585 | "-i", | ||
| 1586 | key.to_str().unwrap(), | ||
| 1587 | "-o", | ||
| 1588 | "StrictHostKeyChecking=no", | ||
| 1589 | "-o", | ||
| 1590 | "UserKnownHostsFile=/dev/null", | ||
| 1591 | "-o", | ||
| 1592 | "IdentitiesOnly=yes", | ||
| 1593 | "-o", | ||
| 1594 | "BatchMode=yes", | ||
| 1595 | "-o", | ||
| 1596 | "ConnectTimeout=5", | ||
| 1597 | "git@127.0.0.1", | ||
| 1598 | remote_cmd, | ||
| 1599 | ]) | ||
| 1600 | .stdin(Stdio::piped()) | 1636 | .stdin(Stdio::piped()) |
| 1601 | .stdout(Stdio::piped()) | 1637 | .stdout(Stdio::piped()) |
| 1602 | .stderr(Stdio::piped()) | 1638 | .stderr(Stdio::piped()) |
tests/delegate_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -124,6 +124,11 @@ fn a_delegate_writes_collab_refs_and_may_not_write_branches() { | |||
| 124 | "delegate collab push failed: {}", | 124 | "delegate collab push failed: {}", |
| 125 | stderr(&push) | 125 | stderr(&push) |
| 126 | ); | 126 | ); |
| 127 | assert!( | ||
| 128 | harness.server_log().contains("claude-a"), | ||
| 129 | "the delegate's key id should be attributed in the server log, got: {}", | ||
| 130 | harness.server_log() | ||
| 131 | ); | ||
| 127 | 132 | ||
| 128 | harness | 133 | harness |
| 129 | .work_repo() | 134 | .work_repo() |
| @@ -354,3 +359,83 @@ fn a_malformed_cadir_file_rejects_the_settings_push() { | |||
| 354 | stderr(&push) | 359 | stderr(&push) |
| 355 | ); | 360 | ); |
| 356 | } | 361 | } |
| 362 | |||
| 363 | /// The threat-model case by name: a delegate must not rewrite `settings` | ||
| 364 | /// itself, the config that governs it — even though the person it acts for | ||
| 365 | /// holds RW+ there. | ||
| 366 | #[test] | ||
| 367 | fn a_delegate_may_not_push_settings_itself() { | ||
| 368 | let harness = ServerHarness::new("delegate-settings-ceiling"); | ||
| 369 | harness.push_head(); | ||
| 370 | harness.bootstrap_settings_with_cas( | ||
| 371 | &access_conf(harness.repo_name()), | ||
| 372 | &[("alex.pub", "alex")], | ||
| 373 | &[("mint/alex.pub", "mint")], | ||
| 374 | ); | ||
| 375 | |||
| 376 | let agent_key = harness.named_key("agent-key"); | ||
| 377 | let ca = harness.delegate_ca("mint"); | ||
| 378 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 379 | |||
| 380 | // A harmless change in the settings work tree — the push itself, not its | ||
| 381 | // content, is what must be refused. | ||
| 382 | let work = harness.settings_work_dir(); | ||
| 383 | std::fs::write(work.join("note.txt"), "n/a").unwrap(); | ||
| 384 | common::git_cmd(&work, &["add", "-A"]); | ||
| 385 | common::git_cmd(&work, &["commit", "-q", "-m", "settings tweak"]); | ||
| 386 | |||
| 387 | let push = harness.ssh_push_from_cert(&work, &agent_key, &cert, "settings", "main:main"); | ||
| 388 | assert!( | ||
| 389 | !push.status.success(), | ||
| 390 | "a delegate rewrote settings, the config that governs it" | ||
| 391 | ); | ||
| 392 | assert!( | ||
| 393 | stderr(&push).contains("refs/collab"), | ||
| 394 | "the refusal should name the ceiling, got: {}", | ||
| 395 | stderr(&push) | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | |||
| 399 | /// Release upload/delete are outside the ceiling even though the person a | ||
| 400 | /// delegate acts for may hold RW+ on the repo; List is Read and stays open. | ||
| 401 | #[test] | ||
| 402 | fn a_delegate_may_list_releases_but_not_publish_them() { | ||
| 403 | let harness = ServerHarness::new("delegate-release-ceiling"); | ||
| 404 | harness.push_head(); | ||
| 405 | harness.bootstrap_settings_with_cas( | ||
| 406 | &access_conf(harness.repo_name()), | ||
| 407 | &[("alex.pub", "alex")], | ||
| 408 | &[("mint/alex.pub", "mint")], | ||
| 409 | ); | ||
| 410 | |||
| 411 | let agent_key = harness.named_key("agent-key"); | ||
| 412 | let ca = harness.delegate_ca("mint"); | ||
| 413 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 414 | |||
| 415 | let upload = harness.ssh_exec_as_cert( | ||
| 416 | &agent_key, | ||
| 417 | &cert, | ||
| 418 | &format!( | ||
| 419 | "collab-release upload '{}.git' 'v1' 'a.tar.gz'", | ||
| 420 | harness.repo_name() | ||
| 421 | ), | ||
| 422 | b"fake tarball bytes", | ||
| 423 | ); | ||
| 424 | assert!( | ||
| 425 | !upload.status.success(), | ||
| 426 | "a delegate published a release: {}", | ||
| 427 | stderr(&upload) | ||
| 428 | ); | ||
| 429 | |||
| 430 | let list = harness.ssh_exec_as_cert( | ||
| 431 | &agent_key, | ||
| 432 | &cert, | ||
| 433 | &format!("collab-release list '{}.git'", harness.repo_name()), | ||
| 434 | b"", | ||
| 435 | ); | ||
| 436 | assert!( | ||
| 437 | list.status.success(), | ||
| 438 | "a delegate could not list releases: {}", | ||
| 439 | stderr(&list) | ||
| 440 | ); | ||
| 441 | } | ||