ACME: implement dns-account-01 challenge type (#996)

* Implement dns-account-01.

* Bump draft versions.

* dns-account-01 implementation changed in Pebble; only the one used by ansible-core 2.21/devel's ACME simulator matches the latest draft.
This commit is contained in:
Felix Fontein
2026-03-29 20:49:33 +02:00
committed by GitHub
parent 4a7d18cad5
commit b1ae295fb7
10 changed files with 182 additions and 38 deletions

View File

@@ -149,9 +149,23 @@ class ACMECertificateClient:
order.load_authorizations(client=self.client)
return order
@staticmethod
def _update_dns_data(
data_dns: dict[str, list[str]],
dns_challenge_type: str,
challenge_data: dict[str, t.Any],
) -> None:
dns_challenge = challenge_data.get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge["record"])
if values is None:
values = []
data_dns[dns_challenge["record"]] = values
values.append(dns_challenge["resource_value"])
def get_challenges_data(
self, order: Order
) -> tuple[list[dict[str, t.Any]], dict[str, list[str]]]:
) -> tuple[list[dict[str, t.Any]], dict[str, list[str]], dict[str, list[str]]]:
"""
Get challenge details.
@@ -159,7 +173,9 @@ class ACMECertificateClient:
"""
data: list[dict[str, t.Any]] = []
data_dns: dict[str, list[str]] = {}
data_dns_account: dict[str, list[str]] = {}
dns_challenge_type = "dns-01"
dns_account_challenge_type = "dns-account-01"
for authz in order.authorizations.values():
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
@@ -173,14 +189,11 @@ class ACMECertificateClient:
"challenges": challenge_data,
}
)
dns_challenge = challenge_data.get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge["record"])
if values is None:
values = []
data_dns[dns_challenge["record"]] = values
values.append(dns_challenge["resource_value"])
return data, data_dns
self._update_dns_data(data_dns, dns_challenge_type, challenge_data)
self._update_dns_data(
data_dns_account, dns_account_challenge_type, challenge_data
)
return data, data_dns, data_dns_account
def check_that_authorizations_can_be_used(self, order: Order) -> None:
bad_authzs = []

View File

@@ -130,6 +130,26 @@ class Challenge:
"record": record,
}
if self.type == "dns-account-01":
if identifier_type != "dns" or client.account_uri is None:
return None
# https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02#section-3.2
prefix = (
base64.b32encode(
hashlib.sha256(client.account_uri.encode("utf8")).digest()[:10]
)
.decode("ascii")
.lower()
)
resource = f"_{prefix}._acme-challenge"
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
record = f"{resource}.{identifier[2:] if identifier.startswith('*.') else identifier}"
return {
"resource": resource,
"resource_value": value,
"record": record,
}
if self.type == "tls-alpn-01":
# https://www.rfc-editor.org/rfc/rfc8737.html#section-3
if identifier_type == "ip":

View File

@@ -12,17 +12,19 @@ short_description: Create SSL/TLS certificates with the ACME protocol
description:
- Create and renew SSL/TLS certificates with a CA supporting the L(ACME protocol,https://tools.ietf.org/html/rfc8555), such
as L(Let's Encrypt,https://letsencrypt.org/).
The current implementation supports the V(http-01), V(dns-01) and V(tls-alpn-01) challenges.
The current implementation supports the V(http-01), V(dns-01), V(dns-account-01) and V(tls-alpn-01) challenges.
- To use this module, it has to be executed twice. Either as two different tasks in the same run or during two runs. Note
that the output of the first run needs to be recorded and passed to the second run as the module argument O(data).
- Between these two tasks you have to fulfill the required steps for the chosen challenge by whatever means necessary. For
V(http-01) that means creating the necessary challenge file on the destination webserver. For V(dns-01) the necessary
DNS record has to be created. For V(tls-alpn-01) the necessary certificate has to be created and served. It is I(not)
the responsibility of this module to perform these steps.
V(http-01) that means creating the necessary challenge file on the destination webserver. For V(dns-01) and V(dns-account-01)
the necessary DNS records have to be created. For V(tls-alpn-01) the necessary certificate has to be created and served.
It is I(not) the responsibility of this module to perform these steps.
- For details on how to fulfill these challenges, you might have to read through L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8)
and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3). Also, consider the examples
provided for this module.
- The module includes experimental support for IP identifiers according to the L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html).
- The module support for IP identifiers according to L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html).
- The module supports the V(dns-account-01) challenge type according to
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
notes:
- At least one of O(dest) and O(fullchain_dest) must be specified.
- This module includes basic account management functionality. If you want to have more control over your ACME account,
@@ -115,13 +117,15 @@ options:
- If set to V(no challenge), no challenge will be used. This is necessary for some private CAs which use External Account
Binding and other means of validating certificate assurance. For example, an account could be allowed to issue certificates
for C(foo.example.com) without any further validation for a certain period of time.
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
type: str
default: 'http-01'
default: http-01
choices:
- 'http-01'
- 'dns-01'
- 'tls-alpn-01'
- 'no challenge'
- http-01
- dns-01
- dns-account-01
- tls-alpn-01
- no challenge
csr:
aliases: ['src']
csr_content:
@@ -261,7 +265,7 @@ options:
description:
- Chose a specific profile for certificate selection. The available profiles depend on the CA.
- See L(a blog post by Let's Encrypt, https://letsencrypt.org/2025/01/09/acme-profiles/) and
L(draft-aaron-acme-profiles-00, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
L(draft-aaron-acme-profiles-01, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
for more information.
type: str
version_added: 2.24.0
@@ -467,7 +471,7 @@ challenge_data:
description:
- Data for every challenge type.
- The keys in this dictionary are the challenge types. C(challenge-type) is a placeholder used in the documentation.
Possible keys are V(http-01), V(dns-01), and V(tls-alpn-01).
Possible keys are V(http-01), V(dns-01), V(dns-account-01), and V(tls-alpn-01).
- Note that the keys are not valid Jinja2 identifiers.
returned: changed
type: dict
@@ -486,7 +490,7 @@ challenge_data:
resource_value:
description:
- The value the resource has to produce for the validation.
- For V(http-01) and V(dns-01) challenges, the value can be used as-is.
- For V(http-01), V(dns-01), and V(dns-account-01) challenges, the value can be used as-is.
- For V(tls-alpn-01) challenges, note that this return value contains a Base64 encoded version of the correct
binary blob which has to be put into the acmeValidation x509 extension; see U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3)
for details. To do this, you might need the P(ansible.builtin.b64decode#filter) Jinja filter to extract
@@ -496,12 +500,12 @@ challenge_data:
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
record:
description: The full DNS record's name for the challenge.
returned: changed and challenge is V(dns-01)
returned: changed and challenge is V(dns-01) or V(dns-account-01)
type: str
sample: _acme-challenge.example.com
challenge_data_dns:
description:
- List of TXT values per DNS record, in case challenge is V(dns-01).
- List of TXT values per DNS record, in case challenge is V(dns-01) or V(dns-account-01).
- Since Ansible 2.8.5, only challenges which are not yet valid are returned.
returned: changed
type: dict
@@ -790,7 +794,10 @@ class ACMECertificateClient:
raise ModuleFailException(
f"Found no challenge of type '{self.challenge}' for identifier {type_identifier}!"
)
if self.challenge == "dns-01" and self.challenge in challenges:
if (
self.challenge in ("dns-01", "dns-account-01")
and self.challenge in challenges
):
values = data_dns.get(challenges[self.challenge]["record"])
if values is None:
values = []
@@ -974,7 +981,13 @@ def main() -> t.NoReturn:
challenge={
"type": "str",
"default": "http-01",
"choices": ["http-01", "dns-01", "tls-alpn-01", NO_CHALLENGE],
"choices": [
"http-01",
"dns-01",
"dns-account-01",
"tls-alpn-01",
NO_CHALLENGE,
],
},
data={"type": "dict"},
dest={"type": "path", "aliases": ["cert"]},

View File

@@ -16,8 +16,8 @@ description:
Authority such as L(Let's Encrypt,https://letsencrypt.org/).
This module does not support ACME v1, the original version of the ACME protocol
before standardization.
- The current implementation supports the V(http-01), V(dns-01) and V(tls-alpn-01)
challenges.
- The current implementation supports the V(http-01), V(dns-01), V(dns-account-01),
and V(tls-alpn-01) challenges.
- This module needs to be used in conjunction with the
M(community.crypto.acme_certificate_order_validate) and.
M(community.crypto.acme_certificate_order_finalize) module.
@@ -29,7 +29,7 @@ description:
- Between the call of this module and M(community.crypto.acme_certificate_order_finalize),
you have to fulfill the required steps for the chosen challenge by whatever means necessary.
For V(http-01) that means creating the necessary challenge file on the destination webserver.
For V(dns-01) the necessary dns record has to be created. For V(tls-alpn-01) the necessary
For V(dns-01) and V(dns-account-01) the necessary DNS records have to be created. For V(tls-alpn-01) the necessary
certificate has to be created and served. It is I(not) the responsibility of this module to
perform these steps.
- For details on how to fulfill these challenges, you might have to read through
@@ -37,7 +37,9 @@ description:
and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3).
Also, consider the examples provided for this module.
- The module includes support for IP identifiers according to
the L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html) ACME extension.
L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html) ACME extension.
- The module supports the V(dns-account-01) challenge type according to
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
seealso:
- module: community.crypto.acme_certificate_order_validate
description: Validate pending authorizations of an ACME order.
@@ -122,7 +124,7 @@ options:
description:
- Chose a specific profile for certificate selection. The available profiles depend on the CA.
- See L(a blog post by Let's Encrypt, https://letsencrypt.org/2025/01/09/acme-profiles/) and
L(draft-aaron-acme-profiles-00, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
L(draft-aaron-acme-profiles-01, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
for more information.
type: str
order_creation_error_strategy:
@@ -316,6 +318,31 @@ challenge_data:
returned: success
type: str
sample: _acme-challenge.example.com
dns-account-01:
description:
- Information for V(dns-account-01) authorization.
- A DNS TXT record needs to be created with the record name RV(challenge_data[].challenges.dns-01.record)
and value RV(challenge_data[].challenges.dns-01.resource_value).
returned: if the identifier supports V(dns-account-01) authorization
version_added: 3.2.0
type: dict
contains:
resource:
description:
- Always ends with the string V(._acme-challenge).
type: str
sample: _ujmmovf2vn55tgye._acme-challenge
resource_value:
description:
- The value the resource has to produce for the validation.
returned: success
type: str
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
record:
description: The full DNS record's name for the challenge.
returned: success
type: str
sample: _ujmmovf2vn55tgye._acme-challenge.example.com
tls-alpn-01:
description:
- Information for V(tls-alpn-01) authorization.
@@ -357,6 +384,13 @@ challenge_data_dns:
- Only challenges which are not yet valid are returned.
returned: success
type: dict
challenge_data_dns_account:
description:
- List of TXT values per DNS record for V(dns-account-01) challenges.
- Only challenges which are not yet valid are returned.
returned: success
type: dict
version_added: 3.2.0
order_uri:
description: ACME order URI.
returned: success
@@ -426,13 +460,14 @@ def main() -> t.NoReturn:
finally:
if module.params["deactivate_authzs"] and order and not done:
client.deactivate_authzs(order)
data, data_dns = client.get_challenges_data(order)
data, data_dns, data_dns_account = client.get_challenges_data(order)
module.exit_json(
changed=True,
order_uri=order.url,
account_uri=client.client.account_uri,
challenge_data=data,
challenge_data_dns=data_dns,
challenge_data_dns_account=data_dns_account,
)
except ModuleFailException as e:
e.do_fail(module=module)

View File

@@ -20,6 +20,8 @@ description:
M(community.crypto.acme_certificate_order_create),
M(community.crypto.acme_certificate_order_validate), and
M(community.crypto.acme_certificate_order_finalize) modules.
- The module supports the V(dns-account-01) challenge type according to
L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02).
seealso:
- module: community.crypto.acme_certificate_order_create
description: Create an ACME order.
@@ -256,11 +258,13 @@ authorizations_by_identifier:
type:
description:
- The type of challenge encoded in the object.
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
type: str
returned: always
choices:
- http-01
- dns-01
- dns-account-01
- tls-alpn-01
url:
description:

View File

@@ -65,11 +65,13 @@ options:
- In case of authorization reuse, or in case of CAs which use External Account Binding
and other means of validating certificate assurance, it might not be necessary
to provide this option.
- Support for V(dns-account-01) has been added in community.crypto 3.2.0.
type: str
choices:
- 'http-01'
- 'dns-01'
- 'tls-alpn-01'
- http-01
- dns-01
- dns-account-01
- tls-alpn-01
order_uri:
description:
- The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri).
@@ -246,7 +248,10 @@ def main() -> t.NoReturn:
argument_spec = create_default_argspec(with_certificate=False)
argument_spec.update_argspec(
order_uri={"type": "str", "required": True},
challenge={"type": "str", "choices": ["http-01", "dns-01", "tls-alpn-01"]},
challenge={
"type": "str",
"choices": ["http-01", "dns-01", "dns-account-01", "tls-alpn-01"],
},
deactivate_authzs={"type": "bool", "default": True},
)
module = argument_spec.create_ansible_module()