9f05919b
Plan: delegate certificates in seven TDD tasks
a73x 2026-08-18 15:42
Commit message
docs/superpowers/plans/2026-08-18-delegate-certificates.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1263 @@ | |||
| 1 | # Delegate Certificates Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** SSH certificates authenticate as short-lived delegates of an enrolled person, able to write `refs/collab/*` and nothing else. | ||
| 6 | |||
| 7 | **Architecture:** `settings.git` grows `cadir/` (per-person CA enrolment, same basename convention as `keydir/`). A new russh `auth_openssh_certificate` handler validates certs against `cadir/` + `keydir/`; the session carries a delegate marker; the existing update hook enforces the hard-coded `refs/collab/` write ceiling via a new env var. No config surface, no client-side change. | ||
| 8 | |||
| 9 | **Tech Stack:** Rust 2021, russh 0.62 (`auth_openssh_certificate`), `ssh_key::Certificate::validate_at`, git2, existing test harness (`tests/common/mod.rs`). | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-08-18-delegate-certificates-design.md` | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - The server code is its own crate rooted at `src/server/` (modules are `crate::governance`, `crate::ssh`, `crate::repos`). | ||
| 16 | - Test expectations for keys/certs come from OpenSSH tooling (`ssh-keygen`), never from our own code path (external-oracle rule; see `src/server/governance/keydir.rs` test comments for the pattern). | ||
| 17 | - No new dependencies. `russh::keys::ssh_key` re-exports `Certificate`, `Fingerprint`, `Error`. | ||
| 18 | - `cargo test` green after every task; `cargo clippy --all-targets` clean; run `rustfmt` only on files you created or edited (repo has pre-existing fmt drift — do not reformat other files). | ||
| 19 | - Commit after every task with a message in the repo's style (imperative sentence about the behavior, why-not-how body), trailer `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. | ||
| 20 | - Delegates on ungoverned servers: reject at auth. Certificates only mean something under governance. | ||
| 21 | |||
| 22 | --- | ||
| 23 | |||
| 24 | ### Task 1: `CaDir` — the per-person CA roster | ||
| 25 | |||
| 26 | **Files:** | ||
| 27 | - Create: `src/server/governance/cadir.rs` | ||
| 28 | - Modify: `src/server/governance/mod.rs` (add `pub mod cadir;` beside `pub mod keydir;`) | ||
| 29 | |||
| 30 | **Interfaces:** | ||
| 31 | - Consumes: `keydir::name_for_path(path: &str) -> Option<&str>`, `keydir::validate_name(name: &str) -> Result<(), String>` (both already exist; `validate_name` is `pub(crate)`). | ||
| 32 | - Produces: | ||
| 33 | - `pub struct CaDir` with `pub fn new() -> Self`, `pub fn insert(&mut self, path: &str, content: &str) -> Result<(), CaDirError>`, `pub fn fingerprints_for(&self, name: &str) -> &[russh::keys::ssh_key::Fingerprint]`, `pub fn is_empty(&self) -> bool`. | ||
| 34 | - `pub enum CaDirError` mirroring `KeyDirError`'s `Malformed`/`BadName` variants (no `Ambiguous` — see below). | ||
| 35 | |||
| 36 | - [ ] **Step 1: Write the failing tests** | ||
| 37 | |||
| 38 | Create `src/server/governance/cadir.rs` with the module doc, an empty `CaDir` skeleton that panics `todo!()` in `insert`, and these tests. Reuse the exact throwaway keys from `keydir.rs`'s tests (copy the constants — the files are read independently): | ||
| 39 | |||
| 40 | ```rust | ||
| 41 | //! `cadir/` — who may act as you. | ||
| 42 | //! | ||
| 43 | //! `keydir/` answers "which keys ARE this person"; `cadir/` answers "which | ||
| 44 | //! CAs may mint delegates OF this person". Same convention: the basename is | ||
| 45 | //! the name, directories are ignored, one trailing `.pub` is stripped. | ||
| 46 | //! | ||
| 47 | //! One deliberate rule difference from `keydir/`: the same CA key enrolled | ||
| 48 | //! under two names is ALLOWED here, not an error. In `keydir/` that is an | ||
| 49 | //! authorization coin-flip, because the connection presents only a | ||
| 50 | //! fingerprint and the server must pick a name. A certificate *names its | ||
| 51 | //! principal*, so the lookup runs the other way — "is this CA enrolled for | ||
| 52 | //! the name the cert claims" — and the same key under two names is simply a | ||
| 53 | //! shared CA that two people have each explicitly opted into. | ||
| 54 | |||
| 55 | use std::collections::HashMap; | ||
| 56 | |||
| 57 | use russh::keys::ssh_key::Fingerprint; | ||
| 58 | use russh::keys::{HashAlg, PublicKey}; | ||
| 59 | |||
| 60 | use super::keydir::{name_for_path, validate_name}; | ||
| 61 | |||
| 62 | #[derive(Debug, thiserror::Error)] | ||
| 63 | pub enum CaDirError { | ||
| 64 | #[error("{path}: not a well-formed OpenSSH public key: {source}")] | ||
| 65 | Malformed { | ||
| 66 | path: String, | ||
| 67 | #[source] | ||
| 68 | source: russh::keys::ssh_key::Error, | ||
| 69 | }, | ||
| 70 | #[error("{path}: {reason}")] | ||
| 71 | BadName { path: String, reason: String }, | ||
| 72 | } | ||
| 73 | |||
| 74 | /// Name-to-CA-fingerprints mapping built from `cadir/`. | ||
| 75 | #[derive(Debug, Default)] | ||
| 76 | pub struct CaDir { | ||
| 77 | by_name: HashMap<String, Vec<Fingerprint>>, | ||
| 78 | } | ||
| 79 | |||
| 80 | impl CaDir { | ||
| 81 | pub fn new() -> Self { | ||
| 82 | Self::default() | ||
| 83 | } | ||
| 84 | |||
| 85 | pub fn insert(&mut self, path: &str, content: &str) -> Result<(), CaDirError> { | ||
| 86 | todo!() | ||
| 87 | } | ||
| 88 | |||
| 89 | /// The CA fingerprints enrolled for `name`. Empty means no CA may mint | ||
| 90 | /// delegates of this person. | ||
| 91 | pub fn fingerprints_for(&self, name: &str) -> &[Fingerprint] { | ||
| 92 | self.by_name.get(name).map(Vec::as_slice).unwrap_or(&[]) | ||
| 93 | } | ||
| 94 | |||
| 95 | pub fn is_empty(&self) -> bool { | ||
| 96 | self.by_name.is_empty() | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | #[cfg(test)] | ||
| 101 | mod tests { | ||
| 102 | use super::*; | ||
| 103 | |||
| 104 | /// Throwaway PUBLIC keys, same provenance discipline as keydir.rs's: | ||
| 105 | /// expected fingerprints are `ssh-keygen -lf`'s answers, not ours. | ||
| 106 | const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab"; | ||
| 107 | const FP_A: &str = "SHA256:h9V15zrr/EYDfNMPefKR+Gf2PpXdfw8M7Fvu9zLjjqY"; | ||
| 108 | const KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDaUXZkX8MTYd4ztPAb11azBoq42VDJowOS3Zuj9jZlg governance-test-b@git-collab"; | ||
| 109 | const FP_B: &str = "SHA256:GauEGK/qjDwFjqhK9/tOqTzcjmf48HCMgBUX6sddJg4"; | ||
| 110 | |||
| 111 | fn fp(s: &str) -> Fingerprint { | ||
| 112 | s.parse().unwrap() | ||
| 113 | } | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | fn a_ca_at_the_top_level_authorizes_its_basename() { | ||
| 117 | let mut cas = CaDir::new(); | ||
| 118 | cas.insert("cadir/alex.pub", KEY_A).unwrap(); | ||
| 119 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 120 | assert!(cas.fingerprints_for("mallory").is_empty()); | ||
| 121 | } | ||
| 122 | |||
| 123 | #[test] | ||
| 124 | fn directories_are_ignored() { | ||
| 125 | let mut cas = CaDir::new(); | ||
| 126 | cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap(); | ||
| 127 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 128 | } | ||
| 129 | |||
| 130 | /// The deliberate difference from keydir/: a shared CA is two explicit | ||
| 131 | /// opt-ins, not an ambiguity. | ||
| 132 | #[test] | ||
| 133 | fn the_same_ca_under_two_names_is_allowed() { | ||
| 134 | let mut cas = CaDir::new(); | ||
| 135 | cas.insert("cadir/shared/alex.pub", KEY_A).unwrap(); | ||
| 136 | cas.insert("cadir/shared/bob.pub", KEY_A).unwrap(); | ||
| 137 | assert_eq!(cas.fingerprints_for("alex"), &[fp(FP_A)]); | ||
| 138 | assert_eq!(cas.fingerprints_for("bob"), &[fp(FP_A)]); | ||
| 139 | } | ||
| 140 | |||
| 141 | #[test] | ||
| 142 | fn two_cas_for_one_name_are_both_kept() { | ||
| 143 | let mut cas = CaDir::new(); | ||
| 144 | cas.insert("cadir/openbao/alex.pub", KEY_A).unwrap(); | ||
| 145 | cas.insert("cadir/laptop/alex.pub", KEY_B).unwrap(); | ||
| 146 | let fps = cas.fingerprints_for("alex"); | ||
| 147 | assert!(fps.contains(&fp(FP_A)) && fps.contains(&fp(FP_B))); | ||
| 148 | } | ||
| 149 | |||
| 150 | #[test] | ||
| 151 | fn the_same_file_twice_is_idempotent() { | ||
| 152 | let mut cas = CaDir::new(); | ||
| 153 | cas.insert("cadir/alex.pub", KEY_A).unwrap(); | ||
| 154 | cas.insert("cadir/mirror/alex.pub", KEY_A).unwrap(); | ||
| 155 | assert_eq!(cas.fingerprints_for("alex").len(), 1); | ||
| 156 | } | ||
| 157 | |||
| 158 | #[test] | ||
| 159 | fn a_malformed_key_is_rejected_naming_its_path() { | ||
| 160 | let mut cas = CaDir::new(); | ||
| 161 | let err = cas.insert("cadir/alex.pub", "not a key").unwrap_err(); | ||
| 162 | assert!(err.to_string().contains("cadir/alex.pub"), "got {err}"); | ||
| 163 | } | ||
| 164 | |||
| 165 | #[test] | ||
| 166 | fn non_pub_files_are_ignored() { | ||
| 167 | let mut cas = CaDir::new(); | ||
| 168 | cas.insert("cadir/README", "not a key").unwrap(); | ||
| 169 | assert!(cas.is_empty()); | ||
| 170 | } | ||
| 171 | |||
| 172 | #[test] | ||
| 173 | fn reserved_and_malformed_names_are_rejected() { | ||
| 174 | let mut cas = CaDir::new(); | ||
| 175 | assert!(cas.insert("cadir/CREATOR.pub", KEY_A).is_err()); | ||
| 176 | assert!(cas.insert("cadir/@admins.pub", KEY_A).is_err()); | ||
| 177 | assert!(cas.insert("cadir/has space.pub", KEY_A).is_err()); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | ``` | ||
| 181 | |||
| 182 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 183 | |||
| 184 | Run: `cargo test --lib cadir 2>&1 | tail -20` (adjust: if `src/server` is a separate binary crate, `cargo test -p <server-crate> cadir` — check `Cargo.toml` for the crate layout; `cargo test cadir` from the workspace root also works) | ||
| 185 | Expected: FAIL — panics on `todo!()`. | ||
| 186 | |||
| 187 | - [ ] **Step 3: Implement `insert`** | ||
| 188 | |||
| 189 | ```rust | ||
| 190 | pub fn insert(&mut self, path: &str, content: &str) -> Result<(), CaDirError> { | ||
| 191 | let Some(name) = name_for_path(path) else { | ||
| 192 | // Not a .pub file; a README in cadir/ is not a CA. | ||
| 193 | return Ok(()); | ||
| 194 | }; | ||
| 195 | validate_name(name).map_err(|reason| CaDirError::BadName { | ||
| 196 | path: path.to_string(), | ||
| 197 | reason, | ||
| 198 | })?; | ||
| 199 | |||
| 200 | let key = | ||
| 201 | PublicKey::from_openssh(content.trim()).map_err(|source| CaDirError::Malformed { | ||
| 202 | path: path.to_string(), | ||
| 203 | source, | ||
| 204 | })?; | ||
| 205 | let fingerprint = key.fingerprint(HashAlg::Sha256); | ||
| 206 | |||
| 207 | let entry = self.by_name.entry(name.to_string()).or_default(); | ||
| 208 | if !entry.contains(&fingerprint) { | ||
| 209 | entry.push(fingerprint); | ||
| 210 | } | ||
| 211 | Ok(()) | ||
| 212 | } | ||
| 213 | ``` | ||
| 214 | |||
| 215 | Replace the `todo!()`. Add `pub mod cadir;` to `src/server/governance/mod.rs` next to `pub mod keydir;`. `validate_name` in `keydir.rs` is `pub(crate)` — that is already visible from a sibling module in the same crate. | ||
| 216 | |||
| 217 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 218 | |||
| 219 | Run: `cargo test cadir 2>&1 | tail -5` | ||
| 220 | Expected: all pass. Also run `cargo clippy --all-targets 2>&1 | grep -E "^(warning|error)"` — expect nothing new. | ||
| 221 | |||
| 222 | - [ ] **Step 5: Commit** | ||
| 223 | |||
| 224 | ```bash | ||
| 225 | git add src/server/governance/cadir.rs src/server/governance/mod.rs | ||
| 226 | git commit -m "cadir/: which CAs may mint delegates of each person" | ||
| 227 | ``` | ||
| 228 | |||
| 229 | --- | ||
| 230 | |||
| 231 | ### Task 2: Governance loads `cadir/`, and the roster rule covers it | ||
| 232 | |||
| 233 | **Files:** | ||
| 234 | - Modify: `src/server/governance/mod.rs` — `Governance` struct (~line 97), `read_tree` (~line 195), `read_keydir`'s neighborhood (~line 213), `check_roster_has_rules` (~line 305) | ||
| 235 | |||
| 236 | **Interfaces:** | ||
| 237 | - Consumes: `CaDir` from Task 1. | ||
| 238 | - Produces: `Governance` gains `pub cas: cadir::CaDir`. `read_tree` populates it from a `cadir/` tree entry (absent → empty). `check_roster_has_rules` treats `cadir/` as roster. | ||
| 239 | |||
| 240 | - [ ] **Step 1: Write the failing tests** | ||
| 241 | |||
| 242 | Add to `mod.rs`'s `#[cfg(test)] mod tests` (it exists, ~line 420; it builds settings trees with `git2::Repository::init_bare` into a `TempDir` — follow the pattern of the existing `roster` test at ~line 467, which commits a tree via `repo.commit(Some("HEAD"), ...)`). Reuse its helper for writing a tree; the existing tests show how blobs are inserted with `TreeBuilder`. Write: | ||
| 243 | |||
| 244 | ```rust | ||
| 245 | /// KEY_A / its ssh-keygen fingerprint, same constants as cadir.rs tests. | ||
| 246 | const CA_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2PlPIF/fKLCQvCHhIUX2FKpQRflVl6CNoQ8aFjIxdG governance-test-a@git-collab"; | ||
| 247 | |||
| 248 | #[test] | ||
| 249 | fn read_tree_loads_cadir() { | ||
| 250 | // Build a tree: conf/access.conf + keydir/alex.pub + cadir/mint/alex.pub | ||
| 251 | // using the same tree-building helper the existing tests use. | ||
| 252 | // Assert: read_tree returns Some(governance) and | ||
| 253 | // governance.cas.fingerprints_for("alex").len() == 1 | ||
| 254 | // and governance.cas.fingerprints_for("bob").is_empty(). | ||
| 255 | } | ||
| 256 | |||
| 257 | #[test] | ||
| 258 | fn cadir_without_rules_is_an_unruled_roster() { | ||
| 259 | // Tree with ONLY cadir/mint/alex.pub (no conf/, no keydir/). | ||
| 260 | // Assert: check_roster_has_rules(&tree).is_err() | ||
| 261 | } | ||
| 262 | |||
| 263 | #[test] | ||
| 264 | fn a_malformed_cadir_file_fails_validation() { | ||
| 265 | // Tree with valid conf + keydir, plus cadir/alex.pub containing "junk". | ||
| 266 | // Assert: validate_settings_tree(...).unwrap_err() names "cadir/alex.pub". | ||
| 267 | } | ||
| 268 | ``` | ||
| 269 | |||
| 270 | Fill the bodies concretely from the neighboring tests' tree-building code — the pattern is `repo.treebuilder(None)`, insert blobs with `repo.blob(...)`, nest subtrees, `builder.write()`. Do not invent a new helper if one already exists in that test module; extend it to take extra `(path, content)` pairs if needed. | ||
| 271 | |||
| 272 | - [ ] **Step 2: Run to verify failure** | ||
| 273 | |||
| 274 | Run: `cargo test --lib governance 2>&1 | tail -15` | ||
| 275 | Expected: `read_tree_loads_cadir` fails to compile (no `cas` field) — that counts as the RED step for a struct change; the other two fail at assert. | ||
| 276 | |||
| 277 | - [ ] **Step 3: Implement** | ||
| 278 | |||
| 279 | In `mod.rs`: | ||
| 280 | |||
| 281 | ```rust | ||
| 282 | const CADIR: &str = "cadir"; | ||
| 283 | ``` | ||
| 284 | |||
| 285 | `Governance` gains the field: | ||
| 286 | |||
| 287 | ```rust | ||
| 288 | pub struct Governance { | ||
| 289 | pub conf: AccessConf, | ||
| 290 | pub keys: KeyDir, | ||
| 291 | pub cas: cadir::CaDir, | ||
| 292 | } | ||
| 293 | ``` | ||
| 294 | |||
| 295 | `read_tree`'s tail becomes: | ||
| 296 | |||
| 297 | ```rust | ||
| 298 | let keys = read_keydir(repo, tree)?; | ||
| 299 | let cas = read_cadir(repo, tree)?; | ||
| 300 | Ok(Some(Governance { conf: access, keys, cas })) | ||
| 301 | ``` | ||
| 302 | |||
| 303 | `read_cadir` mirrors `read_keydir` exactly (collect blobs, sort for deterministic error reporting, insert), with `CADIR` in place of `KEYDIR` and `cadir::CaDir` in place of `KeyDir`. Copy the structure — including the "Collect first, insert after" comment rationale — rather than abstracting the two into one generic walker; two small readers beat one clever one here. | ||
| 304 | |||
| 305 | `check_roster_has_rules`: | ||
| 306 | |||
| 307 | ```rust | ||
| 308 | let has_rules = tree.get_path(Path::new(ACCESS_CONF_PATH)).is_ok(); | ||
| 309 | let has_roster = tree.get_path(Path::new(KEYDIR)).is_ok() | ||
| 310 | || tree.get_path(Path::new(CADIR)).is_ok(); | ||
| 311 | ``` | ||
| 312 | |||
| 313 | and extend its error text to mention both directories (`{KEYDIR}/ or {CADIR}/ without {ACCESS_CONF_PATH}: ...`). | ||
| 314 | |||
| 315 | Fix every construction site of `Governance { .. }` — `read_tree` is the only one in production code; the compiler will find any test sites. | ||
| 316 | |||
| 317 | - [ ] **Step 4: Run the full suite** | ||
| 318 | |||
| 319 | Run: `cargo test 2>&1 | grep -E "test result: FAILED|^error\[" ; echo exit=$?` | ||
| 320 | Expected: no FAILED lines. The governance behavioral tests exercise settings pushes end to end and must still pass untouched. | ||
| 321 | |||
| 322 | - [ ] **Step 5: Commit** | ||
| 323 | |||
| 324 | ```bash | ||
| 325 | git add src/server/governance/mod.rs | ||
| 326 | git commit -m "Governance loads cadir/ and the roster rule covers it" | ||
| 327 | ``` | ||
| 328 | |||
| 329 | --- | ||
| 330 | |||
| 331 | ### Task 3: `delegate::validate` — the whole cert policy in one function | ||
| 332 | |||
| 333 | **Files:** | ||
| 334 | - Create: `src/server/governance/delegate.rs` | ||
| 335 | - Modify: `src/server/governance/mod.rs` (add `pub mod delegate;`) | ||
| 336 | |||
| 337 | **Interfaces:** | ||
| 338 | - Consumes: `Governance` (fields `keys`, `cas` — both pub), `KeyDir::names()`, `CaDir::fingerprints_for`. | ||
| 339 | - Produces: | ||
| 340 | - `pub struct Delegate { pub person: String, pub key_id: String }` | ||
| 341 | - `pub fn validate(cert: &russh::keys::ssh_key::Certificate, governance: &super::Governance, unix_now: u64) -> Result<Delegate, String>` | ||
| 342 | - The `Err` string is a log/debug reason, never sent to the client (auth failures are silent rejects). | ||
| 343 | |||
| 344 | - [ ] **Step 1: Write the failing tests** | ||
| 345 | |||
| 346 | Certs in these tests are minted by real `ssh-keygen` at test time — the external oracle. Test-support helper inside the `#[cfg(test)]` module (uses `tempfile::TempDir` and `std::process::Command`, both already used by this crate's tests): | ||
| 347 | |||
| 348 | ```rust | ||
| 349 | #[cfg(test)] | ||
| 350 | mod tests { | ||
| 351 | use super::*; | ||
| 352 | use std::path::Path; | ||
| 353 | use std::process::Command; | ||
| 354 | |||
| 355 | /// Generate a keypair; returns (private_path, public_content). | ||
| 356 | fn keygen(dir: &Path, name: &str) -> (std::path::PathBuf, String) { | ||
| 357 | let key = dir.join(name); | ||
| 358 | let out = Command::new("ssh-keygen") | ||
| 359 | .args(["-t", "ed25519", "-N", "", "-q", "-C", name]) | ||
| 360 | .arg("-f").arg(&key) | ||
| 361 | .output().unwrap(); | ||
| 362 | assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); | ||
| 363 | let public = std::fs::read_to_string(key.with_extension("pub")).unwrap(); | ||
| 364 | (key, public) | ||
| 365 | } | ||
| 366 | |||
| 367 | /// Mint a certificate with ssh-keygen. `extra` lets a test pass flags like | ||
| 368 | /// ["-h"] (host cert) or ["-O", "force-command=/bin/true"] (critical opt). | ||
| 369 | /// `principals`: None = valid-for-anyone (no -n flag). | ||
| 370 | fn mint(ca: &Path, subject_pub: &Path, key_id: &str, principals: Option<&str>, | ||
| 371 | validity: &str, extra: &[&str]) -> russh::keys::ssh_key::Certificate { | ||
| 372 | let mut cmd = Command::new("ssh-keygen"); | ||
| 373 | cmd.arg("-s").arg(ca).args(["-I", key_id, "-V", validity]).args(extra); | ||
| 374 | if let Some(p) = principals { | ||
| 375 | cmd.args(["-n", p]); | ||
| 376 | } | ||
| 377 | cmd.arg(subject_pub); | ||
| 378 | let out = cmd.output().unwrap(); | ||
| 379 | assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); | ||
| 380 | let cert_path = subject_pub.to_str().unwrap().replace(".pub", "-cert.pub"); | ||
| 381 | let text = std::fs::read_to_string(&cert_path).unwrap(); | ||
| 382 | std::fs::remove_file(&cert_path).unwrap(); // ssh-keygen refuses to overwrite | ||
| 383 | text.trim().parse().unwrap() | ||
| 384 | } | ||
| 385 | |||
| 386 | /// A Governance where `person` is enrolled in keydir/ and `ca_pub` (if | ||
| 387 | /// given) is enrolled for `ca_for` in cadir/. | ||
| 388 | fn governance(person_pub: &str, person: &str, ca_pub: Option<(&str, &str)>) -> Governance { | ||
| 389 | let conf = crate::governance::conf::AccessConf::parse(&format!( | ||
| 390 | "repo settings\n RW+ = {person}\n" | ||
| 391 | )).unwrap(); | ||
| 392 | let mut keys = crate::governance::keydir::KeyDir::new(); | ||
| 393 | keys.insert(&format!("keydir/{person}.pub"), person_pub).unwrap(); | ||
| 394 | let mut cas = crate::governance::cadir::CaDir::new(); | ||
| 395 | if let Some((content, name)) = ca_pub { | ||
| 396 | cas.insert(&format!("cadir/{name}.pub"), content).unwrap(); | ||
| 397 | } | ||
| 398 | Governance { conf, keys, cas } | ||
| 399 | } | ||
| 400 | |||
| 401 | const NOW: u64 = 1_755_500_000; // fixed 'current time', 2026-08; certs minted -1m:+30m around real now also satisfy validate when tests pass real now — so mint with absolute windows instead | ||
| 402 | // ...tests below | ||
| 403 | } | ||
| 404 | ``` | ||
| 405 | |||
| 406 | **Important on time:** mint with an *absolute* validity window that brackets a fixed `NOW`, so tests are deterministic: `-V 20260101000000:20270101000000` with `NOW = 1_755_500_000` (2026-08-18) inside it, and an expired window `-V 20200101000000:20210101000000` for the expiry test. Never call real wall-clock in assertions. | ||
| 407 | |||
| 408 | The tests (each ~6 lines given the helpers): | ||
| 409 | |||
| 410 | ```rust | ||
| 411 | #[test] | ||
| 412 | fn a_cert_from_an_enrolled_ca_naming_an_enrolled_person_validates() { | ||
| 413 | // ca + person keypairs; governance with ca enrolled for "alex"; | ||
| 414 | // mint(ca, person.pub, "claude-a", Some("alex"), VALID_WINDOW, &[]); | ||
| 415 | // validate(...) == Ok(Delegate { person: "alex", key_id: "claude-a" }) | ||
| 416 | } | ||
| 417 | |||
| 418 | #[test] | ||
| 419 | fn a_cert_from_an_unenrolled_ca_is_rejected() { | ||
| 420 | // governance has NO ca for anyone (ca_pub: None) → Err mentions the CA | ||
| 421 | } | ||
| 422 | |||
| 423 | #[test] | ||
| 424 | fn a_ca_enrolled_for_a_different_name_cannot_mint_for_this_one() { | ||
| 425 | // ca enrolled for "bob" in cadir; cert names "alex" → Err | ||
| 426 | } | ||
| 427 | |||
| 428 | #[test] | ||
| 429 | fn an_unenrolled_principal_is_rejected_even_from_a_trusted_ca() { | ||
| 430 | // keydir has "alex"; cert names "mallory"; ca enrolled for "mallory" → Err | ||
| 431 | // (cadir cannot create identity: mallory is not in keydir/) | ||
| 432 | } | ||
| 433 | |||
| 434 | #[test] | ||
| 435 | fn zero_principals_is_rejected() { /* principals: None → Err */ } | ||
| 436 | |||
| 437 | #[test] | ||
| 438 | fn two_principals_are_rejected() { /* Some("alex,bob") → Err */ } | ||
| 439 | |||
| 440 | #[test] | ||
| 441 | fn a_host_certificate_is_rejected() { /* extra: &["-h"], principals Some("alex") → Err */ } | ||
| 442 | |||
| 443 | #[test] | ||
| 444 | fn an_unknown_critical_option_is_rejected() { | ||
| 445 | /* extra: &["-O", "force-command=/bin/true"] → Err */ | ||
| 446 | } | ||
| 447 | |||
| 448 | #[test] | ||
| 449 | fn an_expired_certificate_is_rejected() { | ||
| 450 | /* mint with the 2020 window, NOW in 2026 → Err */ | ||
| 451 | } | ||
| 452 | ``` | ||
| 453 | |||
| 454 | - [ ] **Step 2: Run to verify failure** | ||
| 455 | |||
| 456 | Run: `cargo test delegate 2>&1 | tail -15` | ||
| 457 | Expected: FAIL (module skeleton `todo!()`). | ||
| 458 | |||
| 459 | - [ ] **Step 3: Implement** | ||
| 460 | |||
| 461 | ```rust | ||
| 462 | //! Delegate certificates: the one policy decision, in one place. | ||
| 463 | //! | ||
| 464 | //! A certificate is a delegate of the person it names. This function is the | ||
| 465 | //! entire answer to "is this certificate a valid delegate right now" — auth | ||
| 466 | //! calls it when the connection opens, and the regime calls it again on every | ||
| 467 | //! subsequent command, which is what makes revocation (cadir/ entry removed, | ||
| 468 | //! person's keys removed, cert expired) take effect on the next command | ||
| 469 | //! rather than the next connection. | ||
| 470 | |||
| 471 | use russh::keys::ssh_key::certificate::CertType; | ||
| 472 | use russh::keys::ssh_key::Certificate; | ||
| 473 | |||
| 474 | use super::Governance; | ||
| 475 | |||
| 476 | #[derive(Debug)] | ||
| 477 | pub struct Delegate { | ||
| 478 | pub person: String, | ||
| 479 | pub key_id: String, | ||
| 480 | } | ||
| 481 | |||
| 482 | pub fn validate( | ||
| 483 | cert: &Certificate, | ||
| 484 | governance: &Governance, | ||
| 485 | unix_now: u64, | ||
| 486 | ) -> Result<Delegate, String> { | ||
| 487 | if cert.cert_type() != CertType::User { | ||
| 488 | return Err("not a user certificate".to_string()); | ||
| 489 | } | ||
| 490 | |||
| 491 | // Exactly one principal: a delegate acts for one person. Zero is | ||
| 492 | // OpenSSH's "valid for anyone", which is an anti-goal here. | ||
| 493 | let person = match cert.valid_principals() { | ||
| 494 | [one] => one.clone(), | ||
| 495 | [] => return Err("certificate names no principal".to_string()), | ||
| 496 | many => return Err(format!("certificate names {} principals", many.len())), | ||
| 497 | }; | ||
| 498 | |||
| 499 | // cadir/ delegates identity; it never creates it. The person must exist. | ||
| 500 | if !governance.keys.names().iter().any(|n| *n == person) { | ||
| 501 | return Err(format!("{person} is not enrolled in keydir/")); | ||
| 502 | } | ||
| 503 | |||
| 504 | // Per PROTOCOL.certkeys, an implementation MUST refuse a certificate | ||
| 505 | // carrying a critical option it does not recognize. We recognize none. | ||
| 506 | if let Some((name, _)) = cert.critical_options().iter().next() { | ||
| 507 | return Err(format!("unrecognized critical option {name:?}")); | ||
| 508 | } | ||
| 509 | |||
| 510 | // Signature verifies, signing CA is enrolled *for this person*, and the | ||
| 511 | // timestamp is inside the validity window — all three via validate_at. | ||
| 512 | let fingerprints = governance.cas.fingerprints_for(&person); | ||
| 513 | if fingerprints.is_empty() { | ||
| 514 | return Err(format!("no CA is enrolled in cadir/ for {person}")); | ||
| 515 | } | ||
| 516 | cert.validate_at(unix_now, fingerprints.iter()) | ||
| 517 | .map_err(|e| format!("certificate did not validate for {person}: {e}"))?; | ||
| 518 | |||
| 519 | Ok(Delegate { | ||
| 520 | person, | ||
| 521 | key_id: cert.key_id().to_string(), | ||
| 522 | }) | ||
| 523 | } | ||
| 524 | ``` | ||
| 525 | |||
| 526 | Check the exact `CertType` import path compiles (`ssh_key::certificate::CertType`); if the re-export differs, follow the compiler. Add `pub mod delegate;` to `governance/mod.rs`. | ||
| 527 | |||
| 528 | - [ ] **Step 4: Run to verify pass** | ||
| 529 | |||
| 530 | Run: `cargo test delegate 2>&1 | tail -5` then the full `cargo test`. | ||
| 531 | Expected: all pass. | ||
| 532 | |||
| 533 | - [ ] **Step 5: Commit** | ||
| 534 | |||
| 535 | ```bash | ||
| 536 | git add src/server/governance/delegate.rs src/server/governance/mod.rs | ||
| 537 | git commit -m "Validate a certificate into a delegate of an enrolled person" | ||
| 538 | ``` | ||
| 539 | |||
| 540 | --- | ||
| 541 | |||
| 542 | ### Task 4: Certificates authenticate; the regime knows a delegate | ||
| 543 | |||
| 544 | **Files:** | ||
| 545 | - Modify: `src/server/ssh/session.rs` — `SshHandler` fields (~line 44), `Regime` (~line 15), `regime()` (~line 87), `auth_publickey` (~line 525), exec handler's principal derivation (~line 631), `receive_pack_env` (~line 117), `handle_release_command`'s regime match (~line 197) | ||
| 546 | - Modify: `tests/common/mod.rs` — harness helpers for CAs, certs, and settings-with-cadir | ||
| 547 | - Create: `tests/delegate_test.rs` | ||
| 548 | |||
| 549 | **Interfaces:** | ||
| 550 | - Consumes: `governance::delegate::{validate, Delegate}` from Task 3. | ||
| 551 | - Produces: | ||
| 552 | - `enum AuthIdentity { Key { principal: String }, Delegate { certificate: Box<Certificate> } }` stored as `authenticated: Option<AuthIdentity>` (replaces `authenticated_principal: Option<String>`). | ||
| 553 | - `Regime::Governed` gains `delegate: Option<String>` (the cert key ID; `None` for a person's own key). | ||
| 554 | - `fn regime(&self) -> Regime` (drops the `fingerprint` parameter — it reads `self.authenticated`). | ||
| 555 | - Harness (Task 5 and 6 rely on these exact signatures): | ||
| 556 | - `pub fn delegate_ca(&self, name: &str) -> PathBuf` — keypair under `<root>/cas/<name>`, created once, mirrors `named_key`. | ||
| 557 | - `pub fn mint_cert(&self, ca: &Path, key: &Path, key_id: &str, principals: &str, validity: &str) -> PathBuf` — runs `ssh-keygen -s <ca> -I <key_id> -n <principals> -V <validity> <key>.pub`, returns the `-cert.pub` path. | ||
| 558 | - `pub fn stage_settings_with_cas(&self, access_conf: &str, keys: &[(&str, &str)], cas: &[(&str, &str)])` and `pub fn bootstrap_settings_with_cas(...)` — `cas` maps path-within-`cadir/` → `delegate_ca` name, exactly as `keys` maps into `keydir/`. | ||
| 559 | - `pub fn ssh_push_from_cert(&self, dir: &Path, key: &Path, cert: &Path, repo: &str, refspec: &str) -> Output` and `pub fn ssh_fetch_cert(&self, dir: &Path, key: &Path, cert: &Path, repo: &str) -> Output` — like `ssh_push_from`/`ssh_fetch` but with `-o CertificateFile=<cert>` added to `GIT_SSH_COMMAND` (add `fn ssh_command_for_cert(key: &Path, cert: &Path) -> String` beside `ssh_command_for`, ~line 1782). | ||
| 560 | |||
| 561 | - [ ] **Step 1: Harness helpers** | ||
| 562 | |||
| 563 | In `tests/common/mod.rs`, implement the five helpers above. `delegate_ca` copies `named_key`'s body (~line 1194) with directory `cas` instead of `keys`. `mint_cert`: | ||
| 564 | |||
| 565 | ```rust | ||
| 566 | /// Mint a certificate with ssh-keygen — the external oracle for what a | ||
| 567 | /// valid OpenSSH cert looks like. Returns the `<key>-cert.pub` path. | ||
| 568 | /// `validity` is ssh-keygen's -V syntax, e.g. "-1m:+30m". | ||
| 569 | pub fn mint_cert( | ||
| 570 | &self, | ||
| 571 | ca: &Path, | ||
| 572 | key: &Path, | ||
| 573 | key_id: &str, | ||
| 574 | principals: &str, | ||
| 575 | validity: &str, | ||
| 576 | ) -> PathBuf { | ||
| 577 | let cert = PathBuf::from(format!("{}-cert.pub", key.display())); | ||
| 578 | let _ = std::fs::remove_file(&cert); // ssh-keygen refuses to overwrite | ||
| 579 | let mut cmd = Command::new("ssh-keygen"); | ||
| 580 | cmd.arg("-s").arg(ca).args(["-I", key_id, "-V", validity]); | ||
| 581 | if !principals.is_empty() { | ||
| 582 | cmd.args(["-n", principals]); | ||
| 583 | } | ||
| 584 | cmd.arg(key.with_extension("pub")); | ||
| 585 | let output = cmd.output().expect("failed to run ssh-keygen -s"); | ||
| 586 | assert!( | ||
| 587 | output.status.success(), | ||
| 588 | "ssh-keygen -s failed: {}", | ||
| 589 | String::from_utf8_lossy(&output.stderr) | ||
| 590 | ); | ||
| 591 | cert | ||
| 592 | } | ||
| 593 | ``` | ||
| 594 | |||
| 595 | `stage_settings_with_cas`: copy `stage_settings`'s body (~line 1276) and append a loop writing `cadir/<rel>` from `self.delegate_ca(name).with_extension("pub")`; `bootstrap_settings_with_cas` stages then force-pushes exactly as `bootstrap_settings` does. Then refactor `stage_settings(a, k)` to call `stage_settings_with_cas(a, k, &[])` so there is one body. | ||
| 596 | |||
| 597 | `ssh_command_for_cert`: | ||
| 598 | |||
| 599 | ```rust | ||
| 600 | fn ssh_command_for_cert(key: &Path, cert: &Path) -> String { | ||
| 601 | format!("{} -o CertificateFile={}", ssh_command_for(key), cert.display()) | ||
| 602 | } | ||
| 603 | ``` | ||
| 604 | |||
| 605 | - [ ] **Step 2: Write the failing behavioral tests** | ||
| 606 | |||
| 607 | Create `tests/delegate_test.rs`: | ||
| 608 | |||
| 609 | ```rust | ||
| 610 | mod common; | ||
| 611 | |||
| 612 | use std::process::Output; | ||
| 613 | |||
| 614 | use common::ServerHarness; | ||
| 615 | |||
| 616 | fn stderr(output: &Output) -> String { | ||
| 617 | String::from_utf8_lossy(&output.stderr).into_owned() | ||
| 618 | } | ||
| 619 | |||
| 620 | /// Rules never mention delegates: the person holds the grants, the cert | ||
| 621 | /// borrows them. | ||
| 622 | fn access_conf(repo: &str) -> String { | ||
| 623 | format!( | ||
| 624 | "repo settings\n RW+ = alex\n\nrepo {repo}\n RW+ = alex\n RW+ = bob\n" | ||
| 625 | ) | ||
| 626 | } | ||
| 627 | |||
| 628 | /// A certificate from an enrolled CA, naming an enrolled person, can read | ||
| 629 | /// what the person reads. | ||
| 630 | #[test] | ||
| 631 | fn a_delegate_certificate_authenticates_and_fetches() { | ||
| 632 | let harness = ServerHarness::new("delegate-fetch"); | ||
| 633 | harness.push_head(); | ||
| 634 | harness.bootstrap_settings_with_cas( | ||
| 635 | &access_conf(harness.repo_name()), | ||
| 636 | &[("alex.pub", "alex")], | ||
| 637 | &[("mint/alex.pub", "mint")], | ||
| 638 | ); | ||
| 639 | |||
| 640 | // The delegate's own key is enrolled NOWHERE — that is the point. | ||
| 641 | let agent_key = harness.named_key("agent-key"); | ||
| 642 | let ca = harness.delegate_ca("mint"); | ||
| 643 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 644 | |||
| 645 | let out = harness.ssh_fetch_cert( | ||
| 646 | harness.work_repo().dir.path(), | ||
| 647 | &agent_key, | ||
| 648 | &cert, | ||
| 649 | harness.repo_name(), | ||
| 650 | ); | ||
| 651 | assert!(out.status.success(), "delegate fetch failed: {}", stderr(&out)); | ||
| 652 | } | ||
| 653 | |||
| 654 | /// The same certificate on an ungoverned server is nothing: there is no | ||
| 655 | /// roster to tie its principal to. | ||
| 656 | #[test] | ||
| 657 | fn a_certificate_is_rejected_on_an_ungoverned_server() { | ||
| 658 | let harness = ServerHarness::new("delegate-ungoverned"); | ||
| 659 | let _ = harness.ssh_client_key(); // authorized_keys exists, server ungoverned | ||
| 660 | harness.push_head(); | ||
| 661 | |||
| 662 | let agent_key = harness.named_key("agent-key"); | ||
| 663 | let ca = harness.delegate_ca("mint"); | ||
| 664 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 665 | |||
| 666 | let out = harness.ssh_fetch_cert( | ||
| 667 | harness.work_repo().dir.path(), | ||
| 668 | &agent_key, | ||
| 669 | &cert, | ||
| 670 | harness.repo_name(), | ||
| 671 | ); | ||
| 672 | assert!(!out.status.success(), "an ungoverned server accepted a certificate"); | ||
| 673 | } | ||
| 674 | |||
| 675 | /// A cert whose CA is enrolled for someone else cannot act as this person. | ||
| 676 | #[test] | ||
| 677 | fn a_ca_enrolled_for_another_name_is_rejected() { | ||
| 678 | let harness = ServerHarness::new("delegate-wrong-ca"); | ||
| 679 | harness.push_head(); | ||
| 680 | harness.bootstrap_settings_with_cas( | ||
| 681 | &access_conf(harness.repo_name()), | ||
| 682 | &[("alex.pub", "alex"), ("bob.pub", "bob")], | ||
| 683 | &[("mint/bob.pub", "mint")], // mint may act for bob, NOT alex | ||
| 684 | ); | ||
| 685 | |||
| 686 | let agent_key = harness.named_key("agent-key"); | ||
| 687 | let ca = harness.delegate_ca("mint"); | ||
| 688 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 689 | |||
| 690 | let out = harness.ssh_fetch_cert( | ||
| 691 | harness.work_repo().dir.path(), | ||
| 692 | &agent_key, | ||
| 693 | &cert, | ||
| 694 | harness.repo_name(), | ||
| 695 | ); | ||
| 696 | assert!(!out.status.success(), "a CA enrolled for bob minted a working delegate of alex"); | ||
| 697 | } | ||
| 698 | ``` | ||
| 699 | |||
| 700 | - [ ] **Step 3: Run to verify failure** | ||
| 701 | |||
| 702 | Run: `cargo test --test delegate_test 2>&1 | tail -15` | ||
| 703 | Expected: `a_delegate_certificate_authenticates_and_fetches` FAILS (server has no cert handler → auth rejected); the two rejection tests may pass vacuously — that is fine, they exist to pin the behavior against regressions once the handler lands. | ||
| 704 | |||
| 705 | - [ ] **Step 4: Implement in `session.rs`** | ||
| 706 | |||
| 707 | 1. Identity storage: | ||
| 708 | |||
| 709 | ```rust | ||
| 710 | use russh::keys::ssh_key::Certificate; | ||
| 711 | |||
| 712 | enum AuthIdentity { | ||
| 713 | /// An enrolled key (or authorized_keys, ungoverned): the principal string | ||
| 714 | /// is the fingerprint form ssh_key_principal produces. | ||
| 715 | Key { principal: String }, | ||
| 716 | /// A delegate certificate. Kept whole so the regime can re-validate it | ||
| 717 | /// per request — expiry and cadir/keydir membership are checked on every | ||
| 718 | /// command, not once at connection open. | ||
| 719 | Delegate { certificate: Box<Certificate> }, | ||
| 720 | } | ||
| 721 | ``` | ||
| 722 | |||
| 723 | Replace `authenticated_principal: Option<String>` with `authenticated: Option<AuthIdentity>` (constructor too). In `auth_publickey`'s accept arm, store `AuthIdentity::Key { principal }`. | ||
| 724 | |||
| 725 | 2. New handler, beside `auth_publickey`. russh calls it only after verifying the client holds the cert's private key; russh has also already checked expiry and the embedded signature, but we re-check both via `validate` because the regime path needs them anyway: | ||
| 726 | |||
| 727 | ```rust | ||
| 728 | async fn auth_openssh_certificate( | ||
| 729 | &mut self, | ||
| 730 | _user: &str, | ||
| 731 | certificate: &Certificate, | ||
| 732 | ) -> Result<Auth, Self::Error> { | ||
| 733 | let GovernanceState::Active(governance) = governance::load(&self.config.repos_dir) | ||
| 734 | else { | ||
| 735 | debug!("Certificate auth rejected: server is not governed"); | ||
| 736 | return Ok(Auth::reject()); | ||
| 737 | }; | ||
| 738 | match governance::delegate::validate(certificate, &governance, unix_now()) { | ||
| 739 | Ok(delegate) => { | ||
| 740 | info!( | ||
| 741 | "Certificate auth accepted: {} (via {})", | ||
| 742 | delegate.person, delegate.key_id | ||
| 743 | ); | ||
| 744 | self.authenticated = Some(AuthIdentity::Delegate { | ||
| 745 | certificate: Box::new(certificate.clone()), | ||
| 746 | }); | ||
| 747 | Ok(Auth::Accept) | ||
| 748 | } | ||
| 749 | Err(reason) => { | ||
| 750 | debug!("Certificate auth rejected: {reason}"); | ||
| 751 | Ok(Auth::reject()) | ||
| 752 | } | ||
| 753 | } | ||
| 754 | } | ||
| 755 | ``` | ||
| 756 | |||
| 757 | with, at module level: | ||
| 758 | |||
| 759 | ```rust | ||
| 760 | fn unix_now() -> u64 { | ||
| 761 | std::time::SystemTime::now() | ||
| 762 | .duration_since(std::time::UNIX_EPOCH) | ||
| 763 | .map(|d| d.as_secs()) | ||
| 764 | .unwrap_or(0) | ||
| 765 | } | ||
| 766 | ``` | ||
| 767 | |||
| 768 | (`unwrap_or(0)` fails closed: time-before-epoch validates nothing.) | ||
| 769 | |||
| 770 | 3. `Regime::Governed` gains the marker: | ||
| 771 | |||
| 772 | ```rust | ||
| 773 | Governed { | ||
| 774 | governance: Box<Governance>, | ||
| 775 | name: String, | ||
| 776 | /// `Some(key_id)` when this session is a certificate, acting for | ||
| 777 | /// `name` under the hard-coded collab-refs ceiling. | ||
| 778 | delegate: Option<String>, | ||
| 779 | }, | ||
| 780 | ``` | ||
| 781 | |||
| 782 | 4. `regime()` becomes parameterless and dispatches on identity. The existing body is the `Key` arm (with `delegate: None` added to its `Governed` construction); the new arm: | ||
| 783 | |||
| 784 | ```rust | ||
| 785 | fn regime(&self) -> Regime { | ||
| 786 | let Some(identity) = self.authenticated.as_ref() else { | ||
| 787 | return Regime::Closed; | ||
| 788 | }; | ||
| 789 | match identity { | ||
| 790 | AuthIdentity::Key { principal } => { /* existing body, fingerprint = principal */ } | ||
| 791 | AuthIdentity::Delegate { certificate } => { | ||
| 792 | match governance::load(&self.config.repos_dir) { | ||
| 793 | GovernanceState::Active(governance) => { | ||
| 794 | match governance::delegate::validate(certificate, &governance, unix_now()) | ||
| 795 | { | ||
| 796 | Ok(d) => Regime::Governed { | ||
| 797 | governance, | ||
| 798 | name: d.person, | ||
| 799 | delegate: Some(d.key_id), | ||
| 800 | }, | ||
| 801 | Err(reason) => { | ||
| 802 | warn!("Delegate no longer valid, closing: {reason}"); | ||
| 803 | Regime::Closed | ||
| 804 | } | ||
| 805 | } | ||
| 806 | } | ||
| 807 | GovernanceState::Unreadable(reason) => { | ||
| 808 | error!("Settings repository is unreadable, closing everything: {reason}"); | ||
| 809 | Regime::Closed | ||
| 810 | } | ||
| 811 | // Governance turned off since auth: the delegate's whole | ||
| 812 | // basis is gone. | ||
| 813 | GovernanceState::Absent => Regime::Closed, | ||
| 814 | } | ||
| 815 | } | ||
| 816 | } | ||
| 817 | } | ||
| 818 | ``` | ||
| 819 | |||
| 820 | 5. Exec handler (~line 631): the `principal` local is used for logging and for ungoverned `server.toml` matching. Derive it from the identity: | ||
| 821 | |||
| 822 | ```rust | ||
| 823 | let principal = match self.authenticated.as_ref() { | ||
| 824 | Some(AuthIdentity::Key { principal }) => principal.clone(), | ||
| 825 | Some(AuthIdentity::Delegate { certificate }) => { | ||
| 826 | format!("delegate:{}", certificate.key_id()) | ||
| 827 | } | ||
| 828 | None => { /* existing not-authenticated rejection */ } | ||
| 829 | }; | ||
| 830 | // ... | ||
| 831 | let regime = self.regime(); | ||
| 832 | ``` | ||
| 833 | |||
| 834 | A delegate identity can never reach `Regime::Ungoverned`, so the `delegate:` label never hits `server.toml` policy matching — it only appears in logs. | ||
| 835 | |||
| 836 | 6. Fix every `Regime::Governed { .. }` construction/match site the compiler reports: `receive_pack_env` (~line 148, add `..` for now — Task 5 uses `delegate` there), the exec `authorized` match (~line 693, bind `delegate` but ignore for now), the create-permission block (~line 715), `record_creator` block (~line 737), and `handle_release_command` (~line 197). This task changes no authorization outcomes for delegates beyond what `regime()` yields; the ceiling lands in Task 5. | ||
| 837 | |||
| 838 | - [ ] **Step 5: Run to verify pass, then the whole suite** | ||
| 839 | |||
| 840 | Run: `cargo test --test delegate_test 2>&1 | tail -8`, then `cargo test 2>&1 | grep -cE "test result: FAILED"` (expect `0`), then clippy. | ||
| 841 | Expected: all three delegate tests pass; every existing governance/server test unchanged. | ||
| 842 | |||
| 843 | - [ ] **Step 6: Commit** | ||
| 844 | |||
| 845 | ```bash | ||
| 846 | git add src/server/ssh/session.rs tests/common/mod.rs tests/delegate_test.rs | ||
| 847 | git commit -m "Certificates authenticate as delegates of the person they name" | ||
| 848 | ``` | ||
| 849 | |||
| 850 | --- | ||
| 851 | |||
| 852 | ### Task 5: The ceiling — collab refs only, nothing else | ||
| 853 | |||
| 854 | **Files:** | ||
| 855 | - Modify: `src/server/governance/hook.rs` — env consts (~line 28), `run()` (~line 115) | ||
| 856 | - Modify: `src/server/ssh/session.rs` — `receive_pack_env` (~line 117), exec create block (~line 715), `handle_release_command` (~line 193) | ||
| 857 | - Modify: `tests/delegate_test.rs` | ||
| 858 | |||
| 859 | **Interfaces:** | ||
| 860 | - Consumes: `Regime::Governed { delegate, .. }` from Task 4; hook env plumbing from `receive_pack_env`. | ||
| 861 | - Produces: `pub const ENV_DELEGATE: &str = "GIT_COLLAB_DELEGATE";` in `hook.rs`. Hook refuses any ref outside `refs/collab/` when it is set. Exec denies `Access::Create` and release mutation to delegates. | ||
| 862 | |||
| 863 | - [ ] **Step 1: Write the failing behavioral tests** | ||
| 864 | |||
| 865 | Append to `tests/delegate_test.rs`: | ||
| 866 | |||
| 867 | ```rust | ||
| 868 | /// The ceiling: a delegate writes collab refs with the person's authority — | ||
| 869 | /// and cannot move a branch the person holds RW+ on. | ||
| 870 | #[test] | ||
| 871 | fn a_delegate_writes_collab_refs_and_may_not_write_branches() { | ||
| 872 | let harness = ServerHarness::new("delegate-ceiling"); | ||
| 873 | harness.push_head(); | ||
| 874 | harness.bootstrap_settings_with_cas( | ||
| 875 | &access_conf(harness.repo_name()), | ||
| 876 | &[("alex.pub", "alex")], | ||
| 877 | &[("mint/alex.pub", "mint")], | ||
| 878 | ); | ||
| 879 | |||
| 880 | let agent_key = harness.named_key("agent-key"); | ||
| 881 | let ca = harness.delegate_ca("mint"); | ||
| 882 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 883 | |||
| 884 | harness.work_repo().issue_open("Filed by a delegate"); | ||
| 885 | let push = harness.ssh_push_from_cert( | ||
| 886 | harness.work_repo().dir.path(), | ||
| 887 | &agent_key, | ||
| 888 | &cert, | ||
| 889 | harness.repo_name(), | ||
| 890 | "refs/collab/*:refs/collab/*", | ||
| 891 | ); | ||
| 892 | assert!(push.status.success(), "delegate collab push failed: {}", stderr(&push)); | ||
| 893 | |||
| 894 | harness.work_repo().commit_file("d.txt", "delegate", "delegate commit"); | ||
| 895 | let push = harness.ssh_push_from_cert( | ||
| 896 | harness.work_repo().dir.path(), | ||
| 897 | &agent_key, | ||
| 898 | &cert, | ||
| 899 | harness.repo_name(), | ||
| 900 | "main:main", | ||
| 901 | ); | ||
| 902 | assert!( | ||
| 903 | !push.status.success(), | ||
| 904 | "a delegate moved a branch; alex holds RW+ but the cert must not inherit it" | ||
| 905 | ); | ||
| 906 | assert!( | ||
| 907 | stderr(&push).contains("refs/collab"), | ||
| 908 | "the refusal should name the ceiling, got: {}", | ||
| 909 | stderr(&push) | ||
| 910 | ); | ||
| 911 | } | ||
| 912 | |||
| 913 | /// The person's own key is untouched by the ceiling. | ||
| 914 | #[test] | ||
| 915 | fn the_person_still_writes_branches_directly() { | ||
| 916 | let harness = ServerHarness::new("delegate-person-unaffected"); | ||
| 917 | harness.push_head(); | ||
| 918 | harness.bootstrap_settings_with_cas( | ||
| 919 | &access_conf(harness.repo_name()), | ||
| 920 | &[("alex.pub", "alex")], | ||
| 921 | &[("mint/alex.pub", "mint")], | ||
| 922 | ); | ||
| 923 | harness.work_repo().commit_file("p.txt", "person", "person commit"); | ||
| 924 | let push = harness.ssh_push(&harness.named_key("alex"), "main:main"); | ||
| 925 | assert!(push.status.success(), "the person's own push failed: {}", stderr(&push)); | ||
| 926 | } | ||
| 927 | |||
| 928 | /// Creation is a permission delegates never hold, so CREATOR can never | ||
| 929 | /// resolve to one. | ||
| 930 | #[test] | ||
| 931 | fn a_delegate_may_not_create_a_repository_its_person_could() { | ||
| 932 | let harness = ServerHarness::new("delegate-create"); | ||
| 933 | harness.push_head(); | ||
| 934 | let conf = format!( | ||
| 935 | "repo settings\n RW+ = alex\n\nrepo {}\n RW+ = alex\n\nrepo agents/[a-z-]+\n C = alex\n RW+ = CREATOR\n", | ||
| 936 | harness.repo_name() | ||
| 937 | ); | ||
| 938 | harness.bootstrap_settings_with_cas( | ||
| 939 | &conf, | ||
| 940 | &[("alex.pub", "alex")], | ||
| 941 | &[("mint/alex.pub", "mint")], | ||
| 942 | ); | ||
| 943 | |||
| 944 | let agent_key = harness.named_key("agent-key"); | ||
| 945 | let ca = harness.delegate_ca("mint"); | ||
| 946 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 947 | |||
| 948 | harness.work_repo().commit_file("n.txt", "new", "seed"); | ||
| 949 | let push = harness.ssh_push_from_cert( | ||
| 950 | harness.work_repo().dir.path(), | ||
| 951 | &agent_key, | ||
| 952 | &cert, | ||
| 953 | "agents/claude-a", | ||
| 954 | "main:main", | ||
| 955 | ); | ||
| 956 | assert!(!push.status.success(), "a delegate created a repository"); | ||
| 957 | assert!( | ||
| 958 | !harness.repos_dir().join("agents/claude-a.git").exists(), | ||
| 959 | "the repository must not exist after a refused create" | ||
| 960 | ); | ||
| 961 | } | ||
| 962 | ``` | ||
| 963 | |||
| 964 | - [ ] **Step 2: Run to verify failure** | ||
| 965 | |||
| 966 | Run: `cargo test --test delegate_test 2>&1 | tail -15` | ||
| 967 | Expected: `a_delegate_writes_collab_refs_and_may_not_write_branches` fails at the branch-push assert (delegate currently inherits full RW+); `a_delegate_may_not_create_a_repository_its_person_could` fails (create allowed via person's `C`). The person test passes. | ||
| 968 | |||
| 969 | - [ ] **Step 3: Implement** | ||
| 970 | |||
| 971 | `hook.rs` — new const with the others: | ||
| 972 | |||
| 973 | ```rust | ||
| 974 | /// Set only when the pushing session is a delegate certificate; its value is | ||
| 975 | /// the cert's key ID. Presence is what puts the collab-refs ceiling in force. | ||
| 976 | pub const ENV_DELEGATE: &str = "GIT_COLLAB_DELEGATE"; | ||
| 977 | ``` | ||
| 978 | |||
| 979 | In `run()`, inside the `GovernanceState::Active` block, *before* the rule check (the ceiling is not a rule — it precedes them all): | ||
| 980 | |||
| 981 | ```rust | ||
| 982 | // The delegate ceiling. Hard-coded rather than configured: no line in | ||
| 983 | // access.conf can widen what a certificate may write. | ||
| 984 | let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty()); | ||
| 985 | if let Some(key_id) = &delegate { | ||
| 986 | if !refname.starts_with("refs/collab/") { | ||
| 987 | return Err(format!( | ||
| 988 | "delegate {key_id} of {principal} may only write refs/collab/*, \ | ||
| 989 | not {refname}" | ||
| 990 | )); | ||
| 991 | } | ||
| 992 | } | ||
| 993 | ``` | ||
| 994 | |||
| 995 | `session.rs` `receive_pack_env` — extend the `Governed` arm: | ||
| 996 | |||
| 997 | ```rust | ||
| 998 | if let Regime::Governed { name, delegate, .. } = regime { | ||
| 999 | env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone())); | ||
| 1000 | if let Some(key_id) = delegate { | ||
| 1001 | env.push((governance::hook::ENV_DELEGATE.to_string(), key_id.clone())); | ||
| 1002 | } | ||
| 1003 | } | ||
| 1004 | ``` | ||
| 1005 | |||
| 1006 | Exec create block (~line 715) — deny before consulting the person's rules: | ||
| 1007 | |||
| 1008 | ```rust | ||
| 1009 | if let Regime::Governed { governance, name, delegate } = ®ime { | ||
| 1010 | let allowed = delegate.is_none() | ||
| 1011 | && repo_key.as_deref().is_some_and(|key| { | ||
| 1012 | governance | ||
| 1013 | .conf | ||
| 1014 | .allows_repo(key, &Subject::new(name), Access::Create) | ||
| 1015 | }); | ||
| 1016 | if !allowed { /* existing rejection */ } | ||
| 1017 | } | ||
| 1018 | ``` | ||
| 1019 | |||
| 1020 | `handle_release_command` (~line 193): where `needed` is computed, add after it: | ||
| 1021 | |||
| 1022 | ```rust | ||
| 1023 | // Artifacts are not collab refs. A delegate may list what its person | ||
| 1024 | // may see; publishing and deleting are outside the ceiling. | ||
| 1025 | if let Regime::Governed { delegate: Some(key_id), .. } = regime { | ||
| 1026 | if needed != Access::Read { | ||
| 1027 | warn!("Rejected release command from delegate {key_id}"); | ||
| 1028 | return reply_and_close(session, channel, NOT_FOUND, 1); | ||
| 1029 | } | ||
| 1030 | } | ||
| 1031 | ``` | ||
| 1032 | |||
| 1033 | - [ ] **Step 4: Run to verify pass, then the whole suite and clippy** | ||
| 1034 | |||
| 1035 | Run: `cargo test --test delegate_test 2>&1 | tail -8`; `cargo test 2>&1 | grep -cE "test result: FAILED"` → `0`. | ||
| 1036 | |||
| 1037 | - [ ] **Step 5: Commit** | ||
| 1038 | |||
| 1039 | ```bash | ||
| 1040 | git add src/server/governance/hook.rs src/server/ssh/session.rs tests/delegate_test.rs | ||
| 1041 | git commit -m "Delegates write refs/collab/* and nothing else" | ||
| 1042 | ``` | ||
| 1043 | |||
| 1044 | --- | ||
| 1045 | |||
| 1046 | ### Task 6: Revocation cascades and push validation, end to end | ||
| 1047 | |||
| 1048 | **Files:** | ||
| 1049 | - Modify: `tests/delegate_test.rs` | ||
| 1050 | - Modify (only if a test exposes a gap): `src/server/governance/mod.rs` | ||
| 1051 | |||
| 1052 | **Interfaces:** | ||
| 1053 | - Consumes: everything above. No new production surface expected — these tests pin the properties the spec promises fall out of per-request re-reads and Task 2's validation. | ||
| 1054 | |||
| 1055 | - [ ] **Step 1: Write the tests** | ||
| 1056 | |||
| 1057 | Append to `tests/delegate_test.rs`: | ||
| 1058 | |||
| 1059 | ```rust | ||
| 1060 | /// Removing the CA enrolment kills the delegates it minted, on their next | ||
| 1061 | /// command — no restart, no KRL, and the cert itself is still inside its | ||
| 1062 | /// validity window. | ||
| 1063 | #[test] | ||
| 1064 | fn removing_the_cadir_entry_cuts_the_delegate_off() { | ||
| 1065 | let harness = ServerHarness::new("delegate-revoke-ca"); | ||
| 1066 | harness.push_head(); | ||
| 1067 | harness.bootstrap_settings_with_cas( | ||
| 1068 | &access_conf(harness.repo_name()), | ||
| 1069 | &[("alex.pub", "alex")], | ||
| 1070 | &[("mint/alex.pub", "mint")], | ||
| 1071 | ); | ||
| 1072 | |||
| 1073 | let agent_key = harness.named_key("agent-key"); | ||
| 1074 | let ca = harness.delegate_ca("mint"); | ||
| 1075 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 1076 | |||
| 1077 | let before = harness.ssh_fetch_cert( | ||
| 1078 | harness.work_repo().dir.path(), &agent_key, &cert, harness.repo_name()); | ||
| 1079 | assert!(before.status.success(), "delegate should work before revocation"); | ||
| 1080 | |||
| 1081 | // Re-bootstrap with the cadir entry gone: same conf, same keys, no CAs. | ||
| 1082 | harness.bootstrap_settings_with_cas( | ||
| 1083 | &access_conf(harness.repo_name()), | ||
| 1084 | &[("alex.pub", "alex")], | ||
| 1085 | &[], | ||
| 1086 | ); | ||
| 1087 | |||
| 1088 | let after = harness.ssh_fetch_cert( | ||
| 1089 | harness.work_repo().dir.path(), &agent_key, &cert, harness.repo_name()); | ||
| 1090 | assert!(!after.status.success(), "the delegate outlived its CA enrolment"); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | /// Removing the person kills the person's delegates: cadir/ lends identity, | ||
| 1094 | /// keydir/ is what makes it exist. | ||
| 1095 | #[test] | ||
| 1096 | fn removing_the_person_kills_their_delegates() { | ||
| 1097 | let harness = ServerHarness::new("delegate-revoke-person"); | ||
| 1098 | harness.push_head(); | ||
| 1099 | // Two people, so removing bob leaves a valid config (alex retains RW+ on | ||
| 1100 | // settings — the lockout check requires someone does). | ||
| 1101 | let conf = format!( | ||
| 1102 | "repo settings\n RW+ = alex\n\nrepo {}\n RW+ = alex\n RW+ = bob\n", | ||
| 1103 | harness.repo_name() | ||
| 1104 | ); | ||
| 1105 | harness.bootstrap_settings_with_cas( | ||
| 1106 | &conf, | ||
| 1107 | &[("alex.pub", "alex"), ("bob.pub", "bob")], | ||
| 1108 | &[("mint/bob.pub", "mint")], | ||
| 1109 | ); | ||
| 1110 | |||
| 1111 | let agent_key = harness.named_key("agent-key"); | ||
| 1112 | let ca = harness.delegate_ca("mint"); | ||
| 1113 | let cert = harness.mint_cert(&ca, &agent_key, "bob-agent", "bob", "-1m:+30m"); | ||
| 1114 | |||
| 1115 | let before = harness.ssh_fetch_cert( | ||
| 1116 | harness.work_repo().dir.path(), &agent_key, &cert, harness.repo_name()); | ||
| 1117 | assert!(before.status.success(), "bob's delegate should work while bob exists"); | ||
| 1118 | |||
| 1119 | // bob leaves; his cadir entry remains — and must grant nothing. | ||
| 1120 | harness.bootstrap_settings_with_cas( | ||
| 1121 | &conf, | ||
| 1122 | &[("alex.pub", "alex")], | ||
| 1123 | &[("mint/bob.pub", "mint")], | ||
| 1124 | ); | ||
| 1125 | |||
| 1126 | let after = harness.ssh_fetch_cert( | ||
| 1127 | harness.work_repo().dir.path(), &agent_key, &cert, harness.repo_name()); | ||
| 1128 | assert!(!after.status.success(), "a delegate outlived its person"); | ||
| 1129 | } | ||
| 1130 | |||
| 1131 | /// An expired certificate is rejected at the door. | ||
| 1132 | #[test] | ||
| 1133 | fn an_expired_certificate_does_not_authenticate() { | ||
| 1134 | let harness = ServerHarness::new("delegate-expired"); | ||
| 1135 | harness.push_head(); | ||
| 1136 | harness.bootstrap_settings_with_cas( | ||
| 1137 | &access_conf(harness.repo_name()), | ||
| 1138 | &[("alex.pub", "alex")], | ||
| 1139 | &[("mint/alex.pub", "mint")], | ||
| 1140 | ); | ||
| 1141 | |||
| 1142 | let agent_key = harness.named_key("agent-key"); | ||
| 1143 | let ca = harness.delegate_ca("mint"); | ||
| 1144 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-30m:-1m"); | ||
| 1145 | |||
| 1146 | let out = harness.ssh_fetch_cert( | ||
| 1147 | harness.work_repo().dir.path(), &agent_key, &cert, harness.repo_name()); | ||
| 1148 | assert!(!out.status.success(), "an expired certificate authenticated"); | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | /// A settings push carrying a malformed cadir/ file is refused whole; the | ||
| 1152 | /// previous config keeps governing. | ||
| 1153 | #[test] | ||
| 1154 | fn a_malformed_cadir_file_rejects_the_settings_push() { | ||
| 1155 | let harness = ServerHarness::new("delegate-bad-cadir"); | ||
| 1156 | harness.push_head(); | ||
| 1157 | harness.bootstrap_settings_with_cas( | ||
| 1158 | &access_conf(harness.repo_name()), | ||
| 1159 | &[("alex.pub", "alex")], | ||
| 1160 | &[("mint/alex.pub", "mint")], | ||
| 1161 | ); | ||
| 1162 | |||
| 1163 | // Stage a broken CA file in the settings work tree and push over SSH. | ||
| 1164 | let work = harness.settings_work_dir(); | ||
| 1165 | std::fs::write(work.join("cadir").join("junk.pub"), "not a key").unwrap(); | ||
| 1166 | common::git_cmd(&work, &["add", "-A"]); | ||
| 1167 | common::git_cmd(&work, &["commit", "-q", "-m", "break cadir"]); | ||
| 1168 | |||
| 1169 | let push = harness.push_settings_over_ssh(&harness.named_key("alex")); | ||
| 1170 | assert!(!push.status.success(), "a malformed cadir file was accepted"); | ||
| 1171 | assert!( | ||
| 1172 | stderr(&push).contains("junk.pub"), | ||
| 1173 | "the refusal should name the file, got: {}", | ||
| 1174 | stderr(&push) | ||
| 1175 | ); | ||
| 1176 | } | ||
| 1177 | ``` | ||
| 1178 | |||
| 1179 | `settings_work_dir` does not exist yet: `settings_work()` on the harness is private. Make it `pub fn settings_work_dir(&self) -> PathBuf { self.settings_work() }` in `tests/common/mod.rs` (or just make `settings_work` pub — match the file's existing style of small pub accessors). | ||
| 1180 | |||
| 1181 | - [ ] **Step 2: Run** | ||
| 1182 | |||
| 1183 | Run: `cargo test --test delegate_test 2>&1 | tail -20` | ||
| 1184 | Expected: all four SHOULD pass already — they pin behavior Tasks 2–5 built. If any fails, that is a real gap: debug it (systematic-debugging skill), fix in the named production file, and note in the commit message which property had not actually held. | ||
| 1185 | |||
| 1186 | - [ ] **Step 3: Full suite + clippy + fmt on touched files** | ||
| 1187 | |||
| 1188 | Run: `cargo test 2>&1 | grep -cE "test result: FAILED"` → `0`; `cargo clippy --all-targets` clean; `rustfmt --edition 2021 tests/delegate_test.rs tests/common/mod.rs src/server/governance/cadir.rs src/server/governance/delegate.rs`. | ||
| 1189 | |||
| 1190 | - [ ] **Step 4: Commit** | ||
| 1191 | |||
| 1192 | ```bash | ||
| 1193 | git add tests/delegate_test.rs tests/common/mod.rs | ||
| 1194 | git commit -m "Pin the delegate revocation cascade and cadir push validation" | ||
| 1195 | ``` | ||
| 1196 | |||
| 1197 | --- | ||
| 1198 | |||
| 1199 | ### Task 7: Document the model | ||
| 1200 | |||
| 1201 | **Files:** | ||
| 1202 | - Modify: `README.md` — inside the Governance section, after the `keydir/` explanation (~line 445–460) | ||
| 1203 | |||
| 1204 | - [ ] **Step 1: Write the subsection** | ||
| 1205 | |||
| 1206 | Insert after the paragraph explaining that a principal's name is the basename (~line 452), keeping the README's voice (present tense, mechanism-first, one idea per paragraph): | ||
| 1207 | |||
| 1208 | ```markdown | ||
| 1209 | #### Delegates | ||
| 1210 | |||
| 1211 | A key in `keydir/` is a person. A certificate is a **delegate** of the person | ||
| 1212 | it names, and may write `refs/collab/*` and nothing else. | ||
| 1213 | |||
| 1214 | `cadir/` mirrors `keydir/`, but answers the other question — not "which keys | ||
| 1215 | are this person" but "which CAs may mint delegates of them": | ||
| 1216 | |||
| 1217 | ```text | ||
| 1218 | settings.git | ||
| 1219 | ├── keydir/xps14/alex.pub who you are | ||
| 1220 | └── cadir/mint/alex.pub who may act as you | ||
| 1221 | ``` | ||
| 1222 | |||
| 1223 | Any OpenSSH CA works. Mint a short-lived credential and hand it to an agent: | ||
| 1224 | |||
| 1225 | ```console | ||
| 1226 | $ ssh-keygen -s mint -I claude-a -n alex -V +10m agent_key.pub | ||
| 1227 | ``` | ||
| 1228 | |||
| 1229 | The cert's principal must name an enrolled person and its CA must be enrolled | ||
| 1230 | *for that name* — `cadir/` lends identity, it never creates it. `access.conf` | ||
| 1231 | is never consulted about delegates and cannot widen them: no rule grants a | ||
| 1232 | certificate a branch, a release, or a repository creation. The same CA key | ||
| 1233 | enrolled under two names is allowed (unlike `keydir/`, where one key under two | ||
| 1234 | names is an authorization coin-flip): a certificate names its principal, so | ||
| 1235 | the lookup runs the other way, and a shared CA is two explicit opt-ins. | ||
| 1236 | |||
| 1237 | Revocation is the roster: remove `cadir/mint/alex.pub` and the delegates it | ||
| 1238 | minted die on their next command; remove the person's keys and their | ||
| 1239 | delegates die with them. The cert's own expiry does the rest — there is no | ||
| 1240 | revocation list to maintain. | ||
| 1241 | ``` | ||
| 1242 | |||
| 1243 | (The ` ```text `/` ```console ` fences above are shown escaped; write them as normal fences.) | ||
| 1244 | |||
| 1245 | - [ ] **Step 2: Verify the claims against the tests** | ||
| 1246 | |||
| 1247 | Every sentence in the subsection must be pinned by a test from Tasks 4–6. Re-read both; if a claim has no test, either add the test or cut the claim. | ||
| 1248 | |||
| 1249 | - [ ] **Step 3: Commit** | ||
| 1250 | |||
| 1251 | ```bash | ||
| 1252 | git add README.md | ||
| 1253 | git commit -m "Document delegates: certificates under the collab-refs ceiling" | ||
| 1254 | ``` | ||
| 1255 | |||
| 1256 | --- | ||
| 1257 | |||
| 1258 | ## Self-review notes (already applied) | ||
| 1259 | |||
| 1260 | - Spec coverage: trust layout → T1/T2; authentication → T3/T4; authorization ceiling incl. releases and create → T5; attribution (log lines) → T4 step 4.2; revocation → T6; push validation → T2/T6; testing-as-oracle → every cert minted by `ssh-keygen`; non-goals need no tasks. | ||
| 1261 | - The spec's "certificate presented to an ungoverned server" and "wrong-name CA" behavioral cases live in T4; zero/two-principal, host-cert, critical-option, unenrolled-principal edge cases live in T3 as unit tests against `validate` — the OpenSSH *client* cannot be relied on to transmit all of those shapes, so the function boundary is where they are testable deterministically. | ||
| 1262 | - russh already rejects expired certs and bad embedded signatures before our handler runs; `validate` re-checks anyway because `regime()` calls it per request long after auth. | ||
| 1263 | - Type consistency: `Regime::Governed { governance, name, delegate }` — T4 defines, T5 consumes; `ENV_DELEGATE` — T5 defines and consumes; harness helpers defined in T4 step 1, consumed T4–T6 with matching signatures. | ||