Skip to content

Add the ACME dns-persist-01 challenge - #2826

Draft
joshdrake wants to merge 8 commits into
masterfrom
feat/add-dns-persist-01
Draft

joshdrake wants to merge 8 commits into
masterfrom
feat/add-dns-persist-01

Conversation

@joshdrake

@joshdrake joshdrake commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds support for the ACME dns-persist-01 challenge, as defined in
draft-ietf-acme-dns-persist-02.
It is disabled by default.

To validate a domain, the CA looks up the TXT records at _validation-persist.<domain>. A record must name
one of the CA's issuer domain names. Its accounturi must be the hashed account URI
<accountHashPrefix>sha-256/b64url(SHA-256(len ‖ domain ‖ thumbprint ‖ accountURL)), computed either for
the domain or for *.

Configuration

{
  "type": "ACME",
  "name": "acme",
  "challenges": ["dns-01", "dns-persist-01"],
  "caaIdentities": ["ca.example.com"],
  "issuerDomainNames": ["ca.example.com"],
  "accountHashPrefix": "https://ca.example.com/acme/acme/account-hash/"
}

Both new properties are required when dns-persist-01 is enabled, and are validated in Init:

  • issuerDomainNames: 1 to 10 names, each a lowercase A-label without a trailing dot. If caaIdentities is
    set, every name must also appear in it.
  • accountHashPrefix: an absolute URL prefix containing only characters allowed in an RFC 8659 parameter value.

The directory meta object advertises both values when the challenge is enabled.

Changes

  • authority/provisioner: the DNS_PERSIST_01 challenge type, the two new properties, and their validation.
  • acme:
    • The dns-persist-01 validator.
    • Records are parsed with the RFC 8659 issue-value syntax. Records for other issuers are ignored.
    • Matching records are parsed strictly: duplicate or invalid parameters, a missing or malformed
      accounturi, or an invalid persistUntil are rejected as malformed. Unknown tags are ignored.
    • An account mismatch, an expired persistUntil, or a wildcard identifier without policy=wildcard are
      rejected as unauthorized.
    • persistUntil caps the authorization's expires.
    • Order.Finalize refuses to issue once an authorization validated with dns-persist-01 has expired.
  • acme/api:
    • Creates dns-persist-01 challenges for DNS identifiers, wildcards included.
    • The challenge object carries issuerDomainNames and no token.
  • acme/db/nosql: persists the challenge's issuerDomainNames. UpdateAuthorization now also persists ExpiresAt.
  • authority: converts the challenge type and both properties to and from linkedca. This keeps them intact through
    remote provisioner management and step ca provisioner add/update. Without it, a provisioner configured with
    only dns-persist-01 would fall back to the default challenges after an update.
  • go.mod: pins the Add the dns-persist-01 ACME challenge type linkedca#170 branch until linkedca is released. The newer linkedca requires
    go 1.26.7, so the go directive moves up from 1.26.0.

Design notes

  • accountHashPrefix is explicit config. It is not derived from the request host. The draft requires a
    stable prefix (§4.5), and links built by the linker depend on the request Host.
  • The account URL comes from the account's stored location, the same value that is compared against the JWS
    kid. Legacy accounts without a stored location fail with a clear error instead of hashing a guessed URL.
  • token is now omitempty on acme.Challenge. Existing challenge types always set it, so their JSON
    output is unchanged.
  • acme.Provisioner gains GetIssuerDomainNames() and GetAccountHashPrefix(), following the
    GetAttestationRoots() precedent.
  • Record failures keep the challenge pending, like dns-01, so DNS can be fixed and the challenge retried.
    Account status and stored-location failures mark it invalid.
  • Record errors use NewDetailedError, so clients see why a record was rejected, as device-attest-01 does.
    The details contain only public DNS data and values derived from the requesting account.
  • The persistUntil issuance guard lives in Finalize. It applies only to authorizations with a valid
    dns-persist-01 challenge. An order can still show ready after the deadline, but finalizing it then fails
    with unauthorized. If the authorization was still pending when it expired, the existing UpdateStatus
    logic marks the order invalid.
  • Validation always looks for the record at the identifier's own FQDN.

Not implemented

  • §9.3.1 rate-limiting of failed validations (a MUST). step-ca has no rate-limiting infrastructure. The
    draft's concern is recomputing hashes over long key histories, but keyChange is not implemented in step-ca.
    Each account has exactly one key, so checking a record costs two hashes.
  • §4.2 retention of prior keys. This is moot for the same reason, but it must be revisited if keyChange
    is ever implemented.
  • Optional features: §4.4 just-in-time validation, §6 issuance for subdomains based on a parent's
    policy=wildcard record, and DNSSEC or multi-perspective validation (§7.8). These are MAY or SHOULD in the
    draft.

Testing

  • Unit tests:
    • The provisioner validation.
    • The hash function against the draft's §10.2 test vectors.
    • 41 validator cases covering parsing, case handling, multiple issuers, persistUntil, wildcards, and
      DB errors.
    • Challenge creation, directory metadata, the nosql round-trip, and the Finalize guard.
    • The linkedca round-trip of the challenge type and both properties.
  • golangci-lint with the shared smallstep config: 0 issues.
  • An end-to-end run against a locally built step-ca started with --resolver pointing to an embedded DNS
    server. It used a golang.org/x/crypto/acme client that computes the hashed URIs on its own. All 21 checks passed:
    • The directory meta advertises accountHashPrefix and issuerDomainNames.
    • The challenge object has issuerDomainNames and no token.
    • Validation succeeds from a multi-string TXT record alongside another CA's record. persistUntil caps the
      authorization's expires, and a certificate is issued.
    • A wildcard identifier is rejected without policy=wildcard, with a detailed error. After the record is
      fixed, the same challenge is retried and the wildcard certificate is issued.
    • The domain-independent * form validates.
    • Each failure gets the expected error type:
      • A record for another issuer only: unauthorized.
      • A hashed URI computed for another domain: unauthorized.
      • The raw account URL instead of a hashed URI: malformed.
      • An expired persistUntil: unauthorized.
      • No record at all: dns.
    • After persistUntil passes, finalizing a ready order fails. An order whose authorization was still
      pending becomes invalid.
  • TestBootstrapClientServerRotation (in ca) fails under -race on unmodified master too. It passes
    without -race.

Related

Add the dns-persist-01 challenge type, disabled by default, and the
issuerDomainNames and accountHashPrefix properties required by
draft-ietf-acme-dns-persist-02. Both properties are validated when the
challenge is enabled.
Implement the dns-persist-01 validation method defined in
draft-ietf-acme-dns-persist-02. The validation looks up the TXT records
at _validation-persist.<domain>, ignores the ones for other issuers, and
succeeds if a record binds the domain to the account using the hashed
account URI. Wildcard identifiers require policy=wildcard.

The persistUntil parameter limits the expiration of the authorization,
and orders cannot be finalized once an authorization validated with
dns-persist-01 has expired.
Persist the issuer domain names of dns-persist-01 challenges, and allow
updating the expiration of an authorization, so it can be limited by
the persistUntil parameter of the validation record.
Create dns-persist-01 challenges for DNS identifiers, including
wildcards, when the challenge is enabled in the provisioner. The
challenges include the issuer domain names instead of a token.

The ACME directory advertises the accountHashPrefix and
issuerDomainNames metadata required to provision the validation
records.
Validation records can be provisioned by a party other than the ACME
client, so include the reason a record was rejected in the error detail
to make them easier to fix.
Pin the smallstep/linkedca#170 branch until it is released. The newer
linkedca requires go 1.26.7.
Map the dns-persist-01 challenge type and the issuerDomainNames and
accountHashPrefix properties in both directions. Without this, remote
provisioner management and `step ca provisioner update` silently drop
them, and a provisioner configured with only dns-persist-01 falls back
to the default challenges.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs triage Waiting for discussion / prioritization by team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ACME: Add support for DNS-PERSIST-01

3 participants