a73x

9f05919b

Plan: delegate certificates in seven TDD tasks

a73x   2026-08-18 15:42

Commit message
Plan: delegate certificates in seven TDD tasks

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

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 } = &regime {
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.