ADR-150: Cloudflare token narrowing for ACME DNS-01 (Path A now, EAB later)
Status: Accepted Date: 2026-04-26
Context
Today (post-r1+r3, 2026-04-26), every fleet host that terminates TLS via
acme.sh --dns dns_cf carries a Cloudflare API token on disk with
Zone:DNS:Edit permission scoped to the warp/distill parent zone
(gyrum.ai). That token is exported into the ACME shell environment by
ansible/roles/warp/tasks/cert-preacquire.yml lines 50-58:
export CF_Token='{{ cf_api_token }}'
export CF_Zone_ID='{{ cf_zone_id }}'
~/.acme.sh/acme.sh --issue --dns dns_cf -d {{ warp_hostname }} \
--server letsencrypt --keylength 4096
Operationally the token only needs to create and delete TXT records
matching _acme-challenge.<host> on the zone. It does not need to
mutate A, CNAME, MX, or any other record type, and it does not
need to touch records for hostnames it is not renewing. The current
token is, however, broadly scoped — a host compromise hands the
attacker full DNS-record-mutation power across the parent zone, with
the obvious cascading consequences (mail interception via MX rewrite,
phishing-domain stand-up via wildcard A record, MITM of every other
fleet hostname under the zone via re-pointed A records).
warp #371 r2 (this work) calls out the structural fix: either narrow the on-disk token to per-host scope (Path A), or remove the token from disk entirely by switching to ACME External Account Binding (Path B).
The fleet runs two production hosts on this codepath today (warp-01, warp-02). r4 (separate PR) will score hosts against this toggle in the nightly audit, so whichever path lands here defines what "compliant" means in r4.
Decision
Ship Path A (per-host narrowed CF token) behind a default-false
toggle host_hardening_cf_token_narrow, and document Path B (ACME
EAB) as the natural follow-up once the per-host token migration is
complete on every fleet host.
Concretely:
- A new ansible task file
ansible/roles/host-hardening/tasks/cf-token-narrow.ymlasserts that the per-host token in vault (cf_api_token_<host>) is present and that the zone token (cf_api_token) is absent from the host's active env. It does not mint tokens — token minting is an operator step (Cloudflare requires interactive UI or a parent-account API call we deliberately keep off-host). - A helper script
scripts/mint-cf-host-token.shdocuments the exact Cloudflare API call an operator runs from their workstation (not from the host) to create a token scoped to a single hostname's_acme-challengeTXT records. The script does not store the token; it prints it for the operator to paste into vault. ansible/roles/warp/tasks/cert-preacquire.ymlreadscf_api_token_<host>whenhost_hardening_cf_token_narrow: trueand falls back to the legacycf_api_tokenwhen the toggle is false, so existing hosts keep working until the operator flips the toggle.- A migration runbook in the role's task header documents the order: mint per-host token, write to vault, flip toggle on one host, verify renewal, rotate-out the legacy zone token.
Path B (EAB) is documented under "Alternatives considered" and explicitly named as the next step after Path A is universal — EAB removes the on-disk token entirely but requires per-host EAB credential issuance against Let's Encrypt, an LE account-state change, and a one-way migration we don't want to attempt while the fleet still has hosts on the broad-zone token.
Consequences
Easier:
- Blast radius of a single compromised host drops from "every record in
the zone" to "TXT records matching
_acme-challenge.<host>only". An attacker on warp-01 cannot rewrite warp-02's A record, cannot add an MX record, cannot stand up a phishing subdomain. - The toggle pattern matches r1 / r3 — operators flip per host, fleet re-runs are no-ops, and r4's audit playbook scores against a single boolean.
- Migration is per-host and reversible: flip the toggle off and the host falls back to the legacy zone token until the operator re-runs with the per-host token populated.
- The script is on the operator's workstation, not the host — host compromise cannot mint additional narrowed tokens.
Harder:
- Operators now manage N+1 Cloudflare tokens (one zone token for legacy compatibility + one per-host narrowed token) for the duration of the migration window. The runbook calls out rotating the zone token out of vault once every host has flipped the toggle.
- Token rotation cadence is per-host, not fleet-wide. We accept this as the cost of blast-radius reduction. r4's audit playbook will flag hosts whose narrowed token is older than the rotation policy (separate ticket, post-r4).
- The narrowed token still lives on disk. Path B (EAB) eliminates this entirely; we have signed up to revisit once Path A is universal.
- Cloudflare's API does not let a token narrow itself further than "edit DNS for this zone, restricted to records matching pattern X". In practice "X" is the per-host TXT record name, which is the smallest useful scope.
Signed up to operate:
- Per-host token in vault under
cf_api_token_<host>(key ishost_vars/<host>.ymlorgroup_vars/<group>/vault.yml). - Migration runbook for warp-01 + warp-02 (in this PR's role README).
- Follow-up ticket: rotate-out legacy zone token after every host has flipped the toggle (operator-driven, references warp #374).
- Follow-up ticket / ADR: Path B EAB migration once Path A is universal.
Migration path for warp-01 + warp-02
- Workstation: operator runs
scripts/mint-cf-host-token.sh warp-01.gyrum.aiandscripts/mint-cf-host-token.sh warp-02.gyrum.ai. The script prints two narrowed tokens. - Vault: operator pastes each token into the corresponding
host_vars/<host>.ymlundercf_api_token_<host>. - One host first: operator flips
host_hardening_cf_token_narrow: trueinhost_vars/warp-01.ymland re-runs the warp deploy playbook. Verifies renewal succeeds (check~/.acme.sh/acme.sh --liston warp-01 showsLe_NextRenewTimeadvanced). - Second host: repeat for warp-02.
- Rotate-out: operator deletes the legacy zone token from the
Cloudflare UI and removes
cf_api_tokenfromgroup_vars/warp/vault.yml. Future deploys without the toggle will hard-fail (intentional) — the legacy token no longer exists. - Audit: r4's
audit-host-baseline.yamlscoreshost_hardening_cf_token_narrow == trueas a passing baseline row.
Alternatives considered
Path B — ACME External Account Binding (EAB). Switch
acme.sh --server letsencryptto use--eab-kidand--eab-hmac-keybound to a per-host LE sub-account. The host then renews its own cert against LE without ever holding a Cloudflare token. Stronger posture (no CF token on disk at all) but requires per-host LE EAB credential issuance via LE's commercial-tier account (the free tier doesn't expose EAB), an account-state change, and a one-way migration we don't want to attempt while half the fleet still uses the broad-zone token. Deferred to a follow-up ADR after Path A is universal across every fleet host.Cloudflare Zero Trust token + service binding. CF Zero Trust service tokens can be scoped to specific Worker routes, but the acme.sh
dns_cfprovider does not consume that token shape — it expects a classic API token. A wrapper would have to translate between the two on every renewal. Rejected as more moving parts than the per-host classic token.Run acme.sh from the controller, ship cert+key out. Removes the CF token from the host disk entirely (only the controller has it), but moves a long-lived cert + key over an SSH transport on every renewal cycle and creates a new attack surface (the controller now holds every host's TLS material). Rejected — the failure mode of a controller compromise is worse than the per-host narrowed token failure mode.
Do nothing, accept the broad-zone token. Status quo. The whole point of warp #371's hardening epic is that the status quo is the problem; this alternative is the do-nothing baseline we are moving away from.
Supersedes: none Superseded by: {{leave blank — Path B EAB ADR will supersede this once Path A is universal}}