Skip to content

Add the registrar-endurance scenario for the renewal properties #783

Description

@sehkone

Add the registrar-endurance scenario for the renewal properties

Context

The registrar's client certificate and the endpoint's server certificate are both self-issued and renewed by the daemon under the bootroot-internal privileged credential, with no AppRole anywhere in the renewal loop. That second half is the load-bearing claim: the whole design chose the certificate form to escape an expiring secret_id, so a renewal that quietly authenticated with a role_id and a secret_id would make the top-level guarantee false one layer down, and would surface months later as an unrelated failure.

Both properties share a cost no engineering removes: the only way to prove a certificate was renewed is to outlive the one it replaced. Every other acceptance case for this surface either runs in-process or completes in seconds after one stack bring-up, and both of those arms gate every pull request. These two do not — so they live in their own scenario at the extended tier, and that scenario carries these cases and no others. Anything added here stops gating pull requests, which is a cost, not a convenience.

The tiering has a sharp consequence worth stating: .github/workflows/e2e-extended.yml has no pull-request trigger — only workflow_dispatch and a nightly schedule. A green gh pr checks therefore proves nothing about this scenario, and a linked manual dispatch is the only evidence that counts. That is also exactly why nothing load-bearing for the escalation guarantee may live here, and why this scenario is scoped to the one property that cannot be asserted any other way.

Scope

The scenario and its wiring

  • Script scripts/impl/run-registrar-endurance.sh, cargo wrapper tests/docker_e2e_registrar_endurance.rs, a step in scripts/preflight/ci/e2e-extended.sh, and a step in the run-extended job of .github/workflows/e2e-extended.yml.
  • Source the shared bring-up helper the sibling red-team scenario placed under scripts/impl/lib/ rather than copying its setup. That helper is where the registrar-surface bring-up both Docker scenarios need already lives; scripts/impl/lib/audit-log.sh is the precedent for the placement. Expect it at scripts/impl/lib/registrar-bringup.sh; the sibling issue fixed the directory but left the filename to its own implementer, so if that exact name is not there, do not guess and do not create a second one — identify it as the file scripts/impl/run-registrar-redteam.sh sources for registrar bring-up (grep -n 'lib/' scripts/impl/run-registrar-redteam.sh, which will show it alongside scripts/impl/lib/audit-log.sh) and source that. If neither the expected name nor a sourced registrar helper is in the tree, the dependency has not landed as specified: report that on this issue rather than writing the bring-up inline. If the helper turns out not to cover something this scenario needs, extend the helper — do not fork it.
  • Follow the repository's existing E2E harness conventions: scripts/impl/run-<scenario>.sh plus a tests/docker_e2e_<scenario>.rs wrapper plus a preflight step, in the shape of the two-instance-isolation scenario. Running at a different tier is not a reason to introduce a new harness shape.
  • The scenario must not leave residue in a shared OpenBao instance or collide with a developer's real install on the same machine — derive run-scoped instance names and pick free host ports, exactly as run-two-instance-isolation.sh does.

The two cases

  • Renewal past the original lifetime. Issue the registrar surface's leaves with a short lifetime through the scenario's step-ca configuration; after the original notAfterread from the certificate on disk, not approximated by a fixed sleep — a mint over the socket still succeeds, and the leaf now presented is a different certificate from the one whose notAfter has passed. Assert this for the registrar's client leaf and for the endpoint's server leaf, since the daemon renews both and a scenario that proves only one leaves the other's renewal unexercised at the only tier that can exercise it.
  • No AppRole on the renewal path. Across that same renewal, assert no role_id and no secret_id is read anywhere on the renewal path. Behaviour alone cannot show a negative — a renewal that quietly authenticated with an AppRole produces a valid certificate just the same — so observe the credential-file reads and assert the observed set is empty.
  • That observation is harness-level, and the distinction is the whole reason it is permitted. Watch which files the daemon opens from outside the process — the scenario already runs it in a container it controls, so an access-time or open-path observation on the two credential paths is available without touching bootroot. Nothing is compiled into production code: no feature flag, no counter, no tracing target added for the test, no #[cfg(test)] seam in a shipping path. This is the suite's one named exception to "add no test hooks, injection points or production introspection", and it is an exception only in the sense that the property cannot be asserted from behaviour at all; it is not a licence to add a production seam. If the negative cannot be established from outside the process against the pinned image, report it as a finding on this issue with what was tried — do not add an introspection point to bootroot to make the assertion writable, and do not weaken the case to "the certificate came out valid", which is what the assertion exists to be stronger than. An implementation that renews through the per-service ACME agent path — which authenticates with a role_id + secret_id — does not satisfy this, and this scenario is where that would be caught.

Show the no-AppRole assertion can fail. An assertion that watches for reads which never happen passes just as happily when the watcher is pointed at the wrong paths, mounted in the wrong namespace, or started after the renewal — so this scenario must produce evidence that its watcher fires. Two methods are available, and neither adds anything to bootroot:

  • (a) A throwaway local mutation. Patch the daemon's renewal path to authenticate with role_id + secret_id, run the scenario, confirm it goes red on the no-AppRole assertion, then revert. Nothing is committed; the evidence is the diff and the failing output pasted into the pull request. This is the method that actually exercises a renewal, and it is a one-time claim a reviewer has to take on the strength of that output.
  • (b) A committed harness-level positive control. Inside the container the scenario already controls, deliberately read the two credential paths (a plain cat of role_id and secret_id is enough) at a point where the daemon is not renewing, assert the watcher reports exactly those two reads, then reset the observed set before the real assertion window opens. This proves the observation works against the pinned image and keeps proving it on every run; it does not exercise a renewal.

Do BOTH. This is settled, not a choice. Ship (b) as committed harness code and carry (a)'s diff and failing output as evidence in the pull request. The two cover different failure modes and neither substitutes for the other: (b) re-proves on every run that the watcher is aimed at the right paths in the right mount namespace against the pinned image, and (a) is the only one that exercises a real renewal, which is what catches a watcher that is correctly aimed but started too late to see the renewal's reads. A negative control exists to be falsifiable, and this scenario is the falsification test for the non-self-propagation guarantee this whole RFC is named after — two independent demonstrations are the evidence that guarantee warrants, and this arm is deliberately outside the per-PR gate, so running both costs little. Do not substitute a third method that compiles a seam into bootroot, which the constraints forbid outright.

Waiting is the scenario's cost and must be spent honestly. Drive the wait off the certificate's own notAfter rather than a fixed sleep, so a slow runner does not produce a false pass by asserting before the boundary and a fast one does not idle. Configure the leaf lifetime short enough that the scenario finishes inside the extended job's budget, and report the scenario's wall-clock in the pull request so its cost is a known number.

The sibling arms, by path

The script header and the wrapper's module documentation must name what the other two arms own, and these are the paths to name — a reader who opens only this scenario should be told where the rest of the surface is asserted, and an implementer should not have to open another issue to write that sentence:

  • The per-PR red-team Docker scenarioscripts/impl/run-registrar-redteam.sh, driven by the wrapper tests/docker_e2e_registrar_redteam.rs, with its attack-shape data at tests/e2e/registrar/privileged-policies.txt. It is wired into scripts/preflight/ci/e2e-matrix.sh and gates every pull request, and it is the scenario that owns the shared bring-up helper this one sources.
  • The cargo acceptance arm, which has no single file: most of its cases are #[cfg(test)] modules inside the crate that owns the surface — beside the verb layer in src/commands/ (the binary crate, reached by cargo test --bin bootroot, not by cargo test --lib) and beside the endpoint in the library's daemon module (cargo test --lib) — with tests/*.rs used only for cases reachable through the library's public surface or drivable by spawning the binary, in the shape of tests/bootroot_service.rs. Name those locations and the invocations, since a path alone does not tell a reader how to run them.

If a path above does not exist when this work starts, its owning issue has not landed; report that on this issue rather than inventing a name for it.

Evidence and documentation

  • Because the extended workflow has no pull-request trigger, a manual workflow_dispatch run must be linked from the pull request as the evidence this scenario is green. A passing gh pr checks is not evidence for it.
  • Document the scenario's threat-model boundary in the script header and the wrapper's module documentation — it does not model an exploited request handler, a compromised control plane, or root on the bootroot host — and name the cases the two sibling arms own, using the paths and invocations listed under The sibling arms, by path above, so a reader who opens only this one is told what they are not looking at. Say plainly that this scenario is at the extended tier and does not gate pull requests.

Acceptance criteria

  • The scenario exists as scripts/impl/run-registrar-endurance.sh with the wrapper tests/docker_e2e_registrar_endurance.rs, is invoked by scripts/preflight/ci/e2e-extended.sh, and is a step in the run-extended job of .github/workflows/e2e-extended.yml.
  • It is demonstrated green by a manual workflow_dispatch run linked from the pull request, since that workflow has no pull-request trigger and a green gh pr checks proves nothing about it.
  • The registrar's client leaf is renewed past its original lifetime: after the original notAfter read from the certificate on disk, a mint over the socket still succeeds and the leaf presented is a different certificate.
  • The endpoint's server leaf is renewed past its original lifetime: after its original notAfter, a pinning caller still connects and the leaf presented is a different certificate.
  • Across both renewals, no role_id and no secret_id is read anywhere on the renewal path: the credential-file reads are observed from the harness, outside the daemon process, and the observed set is asserted empty. A reviewer can confirm bootroot gained no feature flag, counter, tracing target or #[cfg(test)] seam for this assertion.
  • The no-AppRole assertion is shown to be falsifiable rather than vacuous by both methods Show the no-AppRole assertion can fail names, with (b) committed and (a)'s evidence carried in the pull request.
  • The wait is driven off the certificates' own notAfter values, not a fixed sleep; a reviewer can confirm no assertion can fire before the boundary has genuinely passed.
  • The scenario carries only these two cases; no assertion that could run in the sibling cargo arm or the per-PR Docker scenario was placed here.
  • It sources the shared bring-up helper under scripts/impl/lib/scripts/impl/lib/registrar-bringup.sh, or whatever name scripts/impl/run-registrar-redteam.sh sources for registrar bring-up — rather than copying the sibling scenario's setup; any gap is closed by extending the helper, not by forking it.
  • The scenario leaves no residue in a shared OpenBao instance and cannot collide with a developer's install: run-scoped instance names and free-port selection, as run-two-instance-isolation.sh does.
  • The scenario's wall-clock is reported in the pull request.
  • The script header and the wrapper's module documentation state the threat-model boundary, name the sibling arms' cases — scripts/impl/run-registrar-redteam.sh with tests/docker_e2e_registrar_redteam.rs for the per-PR Docker arm, and the cargo arm's in-crate locations with the cargo test --bin bootroot / cargo test --lib invocations that reach them — and say plainly that this tier does not gate pull requests.
  • cargo clippy is warning-free and cargo fmt --check passes.

Constraints

  • Do not add a case here that could run anywhere else. The extended workflow has no pull-request trigger, so every case placed here is a case that stops gating pull requests. The two renewal properties are here because waiting is irreducible; nothing else qualifies.
  • Do not accept a case relocated from the per-PR scenario to fit that job's time budget. If the per-PR scenario does not fit, that is a finding to report against it — moving a security assertion into a workflow with no pull-request trigger silently removes the gate while leaving the test file in the tree.
  • Do not approximate the renewal boundary with a fixed sleep. Read the certificate's notAfter and wait against it.
  • Do not weaken the no-AppRole assertion to "not the per-service AppRole". The claim is that the renewal path reads no role_id and no secret_id at all; the bootroot-internal credential the renewal runs under is itself not an AppRole, so the strict form is satisfiable and is what must be asserted.
  • Do not fork the shared bring-up helper; extend it where it falls short.
  • Do not introduce a new E2E harness shape because this scenario runs at a different tier.
  • Do not change any production code to make a case pass — except where a case reveals a genuine defect, which should be fixed with the smallest correct change and called out explicitly.
  • No new fault-injection mechanism, test hook or production introspection point. The no-AppRole assertion's credential-read observation is not an exception to this: it is harness-level, performed on the process from outside, and adds nothing to bootroot. If it cannot be done that way, report it rather than compiling a seam in.

Out of scope

  • Every case that completes without waiting: the escalation-denied assertions, the privileged-policy completeness guard, functionality-preserved and derived-policy-only, authority containment, the CA/responder-HMAC/EAB and per-service-KV unreachability, the audit and capacity cases, and the socket ownership, activation, occupation, peer-credential and pinning assertions. All belong to the sibling per-PR Docker scenario, scripts/impl/run-registrar-redteam.sh with the wrapper tests/docker_e2e_registrar_redteam.rs, which also owns the shared helper this scenario sources.
  • Every case that needs no Docker: derivation from parts, the identity-shape and safe-set refusals, the collision and serialization races, the namespace-key-versus-SAN-label cases, the wire-visible audit and limiter error shapes, lapse-by-variant and the pre-derivation refusals. All belong to the sibling cargo acceptance arm, which lives as #[cfg(test)] modules beside the verb layer in src/commands/ and beside the endpoint in the library's daemon module.
  • Implementing the renewal itself, the reload contract, the health field's certificate entries or the lapse classification — all owned by the certificate-renewal issue and asserted here.
  • Re-asserting the renewal issue's unit-level behaviour, including its torn-pair retry budget, its health payload shapes and its layer-classification table.
  • Performance or load testing.
  • Anything in another repository.

Test plan

The scenario is the deliverable; the criteria above enumerate it. In addition:

  • Demonstrated green locally through scripts/preflight/ci/e2e-extended.sh, and by a manual workflow_dispatch of .github/workflows/e2e-extended.yml with the run linked from the pull request.
  • scripts/preflight/run-all.sh passes end to end, since it invokes the preflight script this scenario is added to.
  • The client-leaf and server-leaf renewals are each asserted separately, so a passing run cannot come from one leaf's renewal covering for the other.
  • The no-AppRole assertion is shown to be load-bearing rather than vacuously true by both falsifiability methods Scope names, and neither alone satisfies this criterion: (a) a throwaway, uncommitted patch that makes the renewal AppRole-authenticated, whose failing output and diff are pasted into the pull request, and (b) a committed harness-level positive control that deliberately reads role_id and secret_id outside the assertion window and asserts the watcher reports exactly those two reads, resetting the observed set before the real assertion window opens.
  • The scenario is re-runnable on the same machine and leaves no residue in a shared OpenBao instance, including on failure paths.

Dependencies

Depends on the certificate-renewal issue for the renewal loop, the reload contract and both leaves it renews — the properties this scenario is the only place able to assert — and on the sibling per-PR red-team scenario (scripts/impl/run-registrar-redteam.sh) for the shared registrar-surface bring-up helper under scripts/impl/lib/. Part of the registrar acceptance suite under the registrar-surface umbrella.

Nothing depends on this scenario. It is the tier of last resort for two properties, and its value is entirely in not letting them go unasserted because they are slow.

Pointers

  • scripts/preflight/ci/e2e-extended.sh and .github/workflows/e2e-extended.yml (run-extended) — where this scenario is wired, and the workflow whose missing pull-request trigger dictates the linked-dispatch evidence rule
  • scripts/preflight/run-all.sh:23:27 — invokes both preflight scripts
  • scripts/impl/run-two-instance-isolation.sh and tests/docker_e2e_two_instance_isolation.rs — the harness shape to follow: run-scoped naming, free-port selection, developer-install safety
  • scripts/impl/lib/audit-log.sh — the precedent for the shared-helper placement this scenario sources from
  • scripts/impl/run-registrar-redteam.sh and tests/docker_e2e_registrar_redteam.rs — the sibling per-PR scenario that creates the bring-up helper, and the first of the two arms whose cases this scenario's documentation names
  • src/config.rs (role_id_path / secret_id_path) — the AppRole reads the renewal path must not make, and the two paths the harness watches from outside the process
  • src/tls.rs:457 — the not_before / not_after validity check, for reading the boundary the wait is driven off
  • src/daemon.rs:68 (run_daemon) — the daemon whose renewal tick the scenario outlives
  • docs/rfcs/0001-registrar-role-and-non-self-propagation.md §4 (the no-AppRole decision and the renewal-maintained certificate) and §6

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions