acme-proxy 0.2.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
# Hardening Checklist

Run through this before an `acme-proxy` deployment issues a certificate anything
depends on. Every item links to the page that explains it; nothing here is
explained only here.

The defaults are already the safe end of most of these. The items that need a
decision from you are marked **decide**.

## Before it serves anything

- [ ] **`challenge.bypass` is `false`.** It is the default. With it on,
  `[filter]`
      is the only thing between a client and a certificate for any name it names.
      → [Challenge Validation]../challenges/index.md#bypass-is-not-a-shortcut
- [ ] **At least one gate is configured** — filters, EAB, or both. Validation
      alone proves the client controls the name; it does not say the client is
      allowed to have a certificate from *you*.
      → [Filters]../filters/index.md, [EAB]../features/eab.md
- [ ] **decide — `server.bind_address` is the interface you meant.** The default
      `[::]:3000` is every interface.
      → [Configuration Reference]../configuration/reference.md#server
- [ ] **`server.base_url` matches how clients actually reach the server**,
      including the scheme. It is checked against the JWS `url` of every signed
      request, so a mismatch fails every request rather than degrading.
      → [Configuration Reference]../configuration/reference.md#server
- [ ] **ACME is served over HTTPS** — either `server.tls.enabled = true` or a
      reverse proxy in front. RFC 8555 §6.1 expects it.
      → [TLS Termination]../features/tls_termination.md
- [ ] **`acme-proxy filter explain` agrees with what you meant**, for both a
      client that should be served and one that should not. A policy is easier
      to get subtly wrong than a list.
      → [CLI]../operations/cli.md#access-policy
- [ ] **`/crl` and `/ca.pem` are still reachable** if any check is
      address-based. Both are served by the profile router, so an allowlist
      covers them too — and neither the relying parties that fetch the CRL nor
      the hosts that have yet to install the root are the ACME clients you
      allowlisted.
      → [Path Check]../filters/path.md#the-crl-and-capem-trap

### An `or` is a hole you opened deliberately

A check that cannot reach its authority answers "unknown" rather than "no", and
`pass or unknown` is `pass`. That is the point — it is what keeps an inventory
outage from locking every client out — but it means **an `or` weakens the
fail-closed property to whatever its other side says**.

```toml
when = "mgmt-net or inventory"
```

reads as "the inventory decides, unless the address is already trusted". If
`mgmt-net` is wide, the inventory is decorative for everything inside it. That
may be exactly what you want; what you must not do is write it believing both
checks apply.

The rule of thumb: **an `or` over an address check is a bypass for that
address range**, so keep the range as small as the outage you are insuring
against. `and` has no such property — `fail and unknown` is `fail`, so a
conjunction never becomes more permissive because something broke.

## The CA key

- [ ] **decide — the issuing key is an intermediate, not a root.** An offline
  root
      means a compromise is recoverable by re-issuing the intermediate rather than
      re-trusting every endpoint.
      → [Local CA]../signers/local_ca.md#multi-tier-pki-using-an-intermediate-ca
- [ ] **decide — the key lives in a PKCS#11 token** if the deployment justifies
      it. The key then cannot be copied, only used.
      → [Hardware Keys]../signers/local_ca_hsm.md
- [ ] **`ca.key` is `0600` and owned by the service user.** `acme-proxy` creates
      it that way; a key restored from a backup may not be.
- [ ] **The CRL is reachable** by everything that validates your certificates,
      and `signer.local_ca.crl_path` is on durable storage — the JSON ledger
      beside it is the authoritative record, not the CRL itself.
      → [Revocation & CRL]../operations/revocation.md

## Behind a proxy

- [ ] **`filter.trusted_proxies` names the hops you trust**, or the forwarded
      header is ignored entirely. Setting `filter.forwarded_header` alone does not
      make it trusted.
      → [Allowed IP]../filters/allowed_ip.md#client-ip-resolution--proxies
- [ ] **The proxy does not forward `/health`** if you do not want it public — it
      is mounted outside the filter chain on purpose.
      → [Monitoring]../operations/monitoring.md#health-checks
- [ ] **With `signer.relay.challenge_strategy = "http01"`**, port 80 of
  every
      name being issued forwards or redirects `/.well-known/acme-challenge/` here.
      Nothing in the process can do this for you.
      → [Relay]../signers/relay.md#deploying-the-http-01-responder

## The web admin

Skip this section entirely if `admin.enabled` is `false`, which is the default.

- [ ] **`admin.bind_address` is loopback**, or `admin.tls.enabled` is `true` —
      startup refuses the other combination.
      → [Web Admin]../operations/webadmin.md#exposing-it
- [ ] **decide — reach it over an SSH tunnel or a VPN** rather than exposing the
      socket. It has no filter chain and no admission control.
      → [Deployment]../getting_started/deployment.md#exposing-the-web-admin-or-rather-not
- [ ] **`admin.base_url` is the origin operators actually type.** It is
      load-bearing four ways — the CSRF origin check, the generated certificate's
      host, and the label an authenticator app shows.
- [ ] **Every operator has a second factor**, and `admin.require_mfa = true` so
      the next one does too. It does not retroactively end sessions that predate
      it; `admin session revoke --all` does.
      → [Users & Sessions]../operations/webadmin_users.md#second-factor-totp
- [ ] **Recovery codes are stored somewhere that is not the panel.** Without
      them, a lost authenticator needs `admin user totp reset` on the host.
      → [Users & Sessions]../operations/webadmin_users.md#recovery-codes
- [ ] **No `--password` anywhere in your provisioning.** There is no such flag;
      the password arrives on stdin or via `--password-file`.
      → [Users & Sessions]../operations/webadmin_users.md#the-password-never-goes-in-argv

## Secrets

- [ ] **Tokens and TSIG keys come from the environment, not the file** where the
      option exists — `ipam.netbox.token`,
      `signer.relay.dns01.rfc2136.tsig_key_secret`.
- [ ] **`[signer.relay.eab]` is emptied after the first registration.** It
  is
      a bootstrap credential that authorizes exactly one `newAccount`; the server
      warns on every startup for as long as it stays set.
      → [Relay]../signers/relay.md#eab-considerations
- [ ] **`insecure_skip_verify` is unset.** It warns on every startup by design,
  so
      it stays visible for exactly as long as it is needed.
      → [NetBox]../ipam/netbox.md
- [ ] **The database file is `0600`.** It holds EAB secrets and TOTP secrets in
  a
      form the server reads back, so anyone who can read the file can too.
      → [Database Schema]../dev/database.md#secrets-are-stored-three-different-ways-on-purpose

## Ongoing

- [ ] **Backups copy the WAL.** `sqlite.db` alone is missing every recent write;
      use `.backup`, or take all three files.
      → [Database Schema]../dev/database.md#reading-it-directly
- [ ] **decide — `audit.retention_days`.** `0`, the default, keeps everything
  for
      ever, which is the right default for a trail whose value is that it is
      complete.
      → [Audit Trail]../operations/audit.md#retention
- [ ] **Something watches the logs for refusals** — `certificate_issue_failed`
      and `certificate_revoke_failed` rows, and a run of unknown-certificate
      revocation attempts, which is somebody enumerating serials.
      → [Monitoring]../operations/monitoring.md#suggested-alerts
- [ ] **Startup warnings are read, not filtered out.** Three of them repeat on
      every start precisely so they cannot become background noise:
      `challenge_validation_bypassed`, `ipam_netbox_tls_verification_disabled`,
      `signer_relay_eab_secret_in_config`.
      → [Monitoring]../operations/monitoring.md#structured-events