Problem
bootroot's HTTP-01 responder HMAC is stored at a single OpenBao KV path (`secret/data/bootroot/responder/hmac`) and the same value is replicated to each service's per-service path (`secret/data/bootroot/services//http_responder_hmac`) by `sync_service_responder_hmac_payloads` (src/commands/rotate/responder_hmac.rs:67-89). All services share the same HMAC value; the per-service paths are just copies for OpenBao template rendering convenience.
The `bootroot-http01` responder validates incoming challenge-registration requests against this HMAC but does not bind the HMAC to a specific set of FQDNs. Any caller that holds the HMAC can register a challenge for any hostname.
Combined with OpenBao AppRole access that hands each service its own copy of the same value, this means:
- Service A's `secret_id` is compromised (offline key extraction, supply chain, misconfigured host, etc.).
- Attacker authenticates to OpenBao with Service A's AppRole.
- Attacker reads Service A's KV copy of the responder HMAC.
- Because the HMAC is global, the attacker now holds a credential that lets them register ACME challenges for any FQDN the responder can serve.
- Attacker runs their own ACME client against step-ca, registers a challenge for Service B's SAN (`001.serviceB.hostB.domain`) via the responder, wins the challenge (responder serves the token because the HMAC signature checks out), and receives a valid certificate for Service B's identity.
The blast radius of one compromised `secret_id` is therefore the entire CA, not the single service. This is the boundary EAB would have mitigated if the bundled CA supported it; since it does not (see #550, #545), the gap is uncovered.
Threat model context
Controls bootroot already has that this issue does not affect:
- OpenBao AppRole authentication: an unauthenticated network-reachable attacker still cannot do any of the above. AppRole secret_id rotation limits the compromise window.
- ACME challenge validation: step-ca still validates the HTTP-01 challenge via the responder. Attackers without OpenBao access cannot forge this.
Controls that are the defense this issue is about:
- Per-service cryptographic boundary on the HMAC — so compromise of one secret_id cannot be used to mint certs for other services.
Proposed fix
Two viable directions, not mutually exclusive:
Option 1 — Per-service HMAC values
- Generate a unique HMAC per service at `service add` time (not a copy of a global value). Store at `secret/data/bootroot/services//http_responder_hmac` as the primary source.
- Delete the global `secret/data/bootroot/responder/hmac`, or keep it only for the responder's startup bootstrap of its known-HMACs set.
- `rotate responder-hmac` becomes per-service: `rotate responder-hmac --service-name ` or an `--all` flag to rotate every service.
- Responder maintains a map of `svc_name → hmac` (populated via OpenBao Agent template render) and validates incoming registrations against the HMAC corresponding to the service whose SAN the target FQDN belongs to (the SAN pattern `...` is already bootroot-enforced, so the responder can parse `` from the registered FQDN and pick the right HMAC).
Stronger boundary: an attacker with Service A's HMAC cannot register challenges for Service B's FQDNs at all, because the responder's validation uses a different key.
Option 2 — Responder enforces FQDN ↔ HMAC pairing
- Keep the global HMAC value (or multiple; doesn't matter).
- When a service registers a challenge, the responder parses `` out of the challenge FQDN and asserts the claimed service matches a service registry it loads from bootroot state.
- Additionally, service-side, when bootroot-agent submits a registration it signs a payload that includes the intended FQDN; the responder rejects registrations where the signing key's owner does not own the FQDN.
Weaker than Option 1 (same HMAC value, but FQDN-tied authz); probably cheaper to implement. Option 1 is the cleaner boundary.
Combined path
Implement Option 1 (per-service HMAC) and have the responder additionally validate FQDN ownership (Option 2's assertion) as belt-and-suspenders. This way compromise of a service's HMAC has two independent failure conditions before lateral cert issuance is possible.
Migration considerations
- `rotate responder-hmac` currently rotates one value; the new per-service model needs either a new `--all` mode or a consistent default. Decide defaults during design.
- OpenBao policies attached to each service's AppRole must allow reading only that service's HMAC path. Today the per-service copies already exist; policy scoping should already be tight. Verify as part of the fix.
- Responder sidecar (`bootroot-http01-responder`) needs to load the service registry and a map of per-service HMACs at startup and on rotate. Template rendering pipeline already exists for the single HMAC; extending to many should be straightforward but requires responder changes.
Related
Environment
- bootroot at `5ff9993` (main tip, 2026-04-19)
- Observation is topology-independent (applies equally to local-file and remote-bootstrap)
Problem
bootroot's HTTP-01 responder HMAC is stored at a single OpenBao KV path (`secret/data/bootroot/responder/hmac`) and the same value is replicated to each service's per-service path (`secret/data/bootroot/services//http_responder_hmac`) by `sync_service_responder_hmac_payloads` (src/commands/rotate/responder_hmac.rs:67-89). All services share the same HMAC value; the per-service paths are just copies for OpenBao template rendering convenience.
The `bootroot-http01` responder validates incoming challenge-registration requests against this HMAC but does not bind the HMAC to a specific set of FQDNs. Any caller that holds the HMAC can register a challenge for any hostname.
Combined with OpenBao AppRole access that hands each service its own copy of the same value, this means:
The blast radius of one compromised `secret_id` is therefore the entire CA, not the single service. This is the boundary EAB would have mitigated if the bundled CA supported it; since it does not (see #550, #545), the gap is uncovered.
Threat model context
Controls bootroot already has that this issue does not affect:
Controls that are the defense this issue is about:
Proposed fix
Two viable directions, not mutually exclusive:
Option 1 — Per-service HMAC values
Stronger boundary: an attacker with Service A's HMAC cannot register challenges for Service B's FQDNs at all, because the responder's validation uses a different key.
Option 2 — Responder enforces FQDN ↔ HMAC pairing
Weaker than Option 1 (same HMAC value, but FQDN-tied authz); probably cheaper to implement. Option 1 is the cleaner boundary.
Combined path
Implement Option 1 (per-service HMAC) and have the responder additionally validate FQDN ownership (Option 2's assertion) as belt-and-suspenders. This way compromise of a service's HMAC has two independent failure conditions before lateral cert issuance is possible.
Migration considerations
Related
Environment