a73x

f596e34b

Pin the delegate revocation cascade and cadir push validation

a73x   2026-08-18 17:33

Commit message
Pin the delegate revocation cascade and cadir push validation

Four behavioral tests confirm the revocation properties the spec
promises fall out of per-request re-reads and Task 2's push
validation, without any production change: dropping a cadir/ entry
or a person's keydir/ entry cuts off their delegates immediately (no
restart, no KRL), an expired certificate never authenticates, and a
settings push carrying a malformed cadir/ file is refused whole with
the previous config staying live. All four passed on first run.

stage_settings_with_cas wrote into a persistent settings-work tree
but only ever added files, so a second call describing fewer
keys/CAs than the first left the earlier ones on disk — the
revocation tests would have passed for the wrong reason (the removed
entry never actually left the tree). It now clears keydir/ and
cadir/ before writing, the way stage_keydir_only already clears
conf/, so each call describes the tree in full.

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

tests/common/mod.rs
Old New
@@ -1307,6 +1307,12 @@ impl ServerHarness {
1307 self.root.path().join("settings-work") 1307 self.root.path().join("settings-work")
1308 } 1308 }
1309 1309
1310 /// The settings working tree, for tests that stage a change by hand
1311 /// rather than through one of the `stage_*`/`bootstrap_*` helpers.
1312 pub fn settings_work_dir(&self) -> PathBuf {
1313 self.settings_work()
1314 }
1315
1310 /// Create `settings.git` and a working tree for it, the way an init 1316 /// Create `settings.git` and a working tree for it, the way an init
1311 /// container would: over the filesystem, before the server is governed. 1317 /// container would: over the filesystem, before the server is governed.
1312 fn ensure_settings_repos(&self) { 1318 fn ensure_settings_repos(&self) {
@@ -1353,6 +1359,18 @@ impl ServerHarness {
1353 std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap(); 1359 std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap();
1354 std::fs::write(&conf_path, access_conf).unwrap(); 1360 std::fs::write(&conf_path, access_conf).unwrap();
1355 1361
1362 // Clear both dirs first: this call describes the roster and the CA
1363 // enrolment *in full*, so a key or CA dropped from the arguments
1364 // must actually disappear from the tree, not just fail to be added.
1365 let keydir = work.join("keydir");
1366 if keydir.exists() {
1367 std::fs::remove_dir_all(&keydir).unwrap();
1368 }
1369 let cadir = work.join("cadir");
1370 if cadir.exists() {
1371 std::fs::remove_dir_all(&cadir).unwrap();
1372 }
1373
1356 for (rel, key_name) in keys { 1374 for (rel, key_name) in keys {
1357 let dest = work.join("keydir").join(rel); 1375 let dest = work.join("keydir").join(rel);
1358 std::fs::create_dir_all(dest.parent().unwrap()).unwrap(); 1376 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
tests/delegate_test.rs
Old New
@@ -201,3 +201,156 @@ fn a_delegate_may_not_create_a_repository_its_person_could() {
201 "the repository must not exist after a refused create" 201 "the repository must not exist after a refused create"
202 ); 202 );
203 } 203 }
204
205 /// Removing the CA enrolment kills the delegates it minted, on their next
206 /// command — no restart, no KRL, and the cert itself is still inside its
207 /// validity window.
208 #[test]
209 fn removing_the_cadir_entry_cuts_the_delegate_off() {
210 let harness = ServerHarness::new("delegate-revoke-ca");
211 harness.push_head();
212 harness.bootstrap_settings_with_cas(
213 &access_conf(harness.repo_name()),
214 &[("alex.pub", "alex")],
215 &[("mint/alex.pub", "mint")],
216 );
217
218 let agent_key = harness.named_key("agent-key");
219 let ca = harness.delegate_ca("mint");
220 let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m");
221
222 let before = harness.ssh_fetch_cert(
223 harness.work_repo().dir.path(),
224 &agent_key,
225 &cert,
226 harness.repo_name(),
227 );
228 assert!(
229 before.status.success(),
230 "delegate should work before revocation"
231 );
232
233 // Re-bootstrap with the cadir entry gone: same conf, same keys, no CAs.
234 harness.bootstrap_settings_with_cas(
235 &access_conf(harness.repo_name()),
236 &[("alex.pub", "alex")],
237 &[],
238 );
239
240 let after = harness.ssh_fetch_cert(
241 harness.work_repo().dir.path(),
242 &agent_key,
243 &cert,
244 harness.repo_name(),
245 );
246 assert!(
247 !after.status.success(),
248 "the delegate outlived its CA enrolment"
249 );
250 }
251
252 /// Removing the person kills the person's delegates: cadir/ lends identity,
253 /// keydir/ is what makes it exist.
254 #[test]
255 fn removing_the_person_kills_their_delegates() {
256 let harness = ServerHarness::new("delegate-revoke-person");
257 harness.push_head();
258 // Two people, so removing bob leaves a valid config (alex retains RW+ on
259 // settings — the lockout check requires someone does).
260 let conf = format!(
261 "repo settings\n RW+ = alex\n\nrepo {}\n RW+ = alex\n RW+ = bob\n",
262 harness.repo_name()
263 );
264 harness.bootstrap_settings_with_cas(
265 &conf,
266 &[("alex.pub", "alex"), ("bob.pub", "bob")],
267 &[("mint/bob.pub", "mint")],
268 );
269
270 let agent_key = harness.named_key("agent-key");
271 let ca = harness.delegate_ca("mint");
272 let cert = harness.mint_cert(&ca, &agent_key, "bob-agent", "bob", "-1m:+30m");
273
274 let before = harness.ssh_fetch_cert(
275 harness.work_repo().dir.path(),
276 &agent_key,
277 &cert,
278 harness.repo_name(),
279 );
280 assert!(
281 before.status.success(),
282 "bob's delegate should work while bob exists"
283 );
284
285 // bob leaves; his cadir entry remains — and must grant nothing.
286 harness.bootstrap_settings_with_cas(
287 &conf,
288 &[("alex.pub", "alex")],
289 &[("mint/bob.pub", "mint")],
290 );
291
292 let after = harness.ssh_fetch_cert(
293 harness.work_repo().dir.path(),
294 &agent_key,
295 &cert,
296 harness.repo_name(),
297 );
298 assert!(!after.status.success(), "a delegate outlived its person");
299 }
300
301 /// An expired certificate is rejected at the door.
302 #[test]
303 fn an_expired_certificate_does_not_authenticate() {
304 let harness = ServerHarness::new("delegate-expired");
305 harness.push_head();
306 harness.bootstrap_settings_with_cas(
307 &access_conf(harness.repo_name()),
308 &[("alex.pub", "alex")],
309 &[("mint/alex.pub", "mint")],
310 );
311
312 let agent_key = harness.named_key("agent-key");
313 let ca = harness.delegate_ca("mint");
314 let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-30m:-1m");
315
316 let out = harness.ssh_fetch_cert(
317 harness.work_repo().dir.path(),
318 &agent_key,
319 &cert,
320 harness.repo_name(),
321 );
322 assert!(
323 !out.status.success(),
324 "an expired certificate authenticated"
325 );
326 }
327
328 /// A settings push carrying a malformed cadir/ file is refused whole; the
329 /// previous config keeps governing.
330 #[test]
331 fn a_malformed_cadir_file_rejects_the_settings_push() {
332 let harness = ServerHarness::new("delegate-bad-cadir");
333 harness.push_head();
334 harness.bootstrap_settings_with_cas(
335 &access_conf(harness.repo_name()),
336 &[("alex.pub", "alex")],
337 &[("mint/alex.pub", "mint")],
338 );
339
340 // Stage a broken CA file in the settings work tree and push over SSH.
341 let work = harness.settings_work_dir();
342 std::fs::write(work.join("cadir").join("junk.pub"), "not a key").unwrap();
343 common::git_cmd(&work, &["add", "-A"]);
344 common::git_cmd(&work, &["commit", "-q", "-m", "break cadir"]);
345
346 let push = harness.push_settings_over_ssh(&harness.named_key("alex"));
347 assert!(
348 !push.status.success(),
349 "a malformed cadir file was accepted"
350 );
351 assert!(
352 stderr(&push).contains("junk.pub"),
353 "the refusal should name the file, got: {}",
354 stderr(&push)
355 );
356 }