Skip to content

HTTP-01 responder HMAC is effectively global; a compromised service secret_id can issue certs for other services' SANs #558

@sehkone

Description

@sehkone

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:

  1. Service A's `secret_id` is compromised (offline key extraction, supply chain, misconfigured host, etc.).
  2. Attacker authenticates to OpenBao with Service A's AppRole.
  3. Attacker reads Service A's KV copy of the responder HMAC.
  4. Because the HMAC is global, the attacker now holds a credential that lets them register ACME challenges for any FQDN the responder can serve.
  5. 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)

Metadata

Metadata

Assignees

Labels

architectureStructural/design proposal

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions