From b1ae295fb72e8d561d49eee44c80c085e300389b Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 29 Mar 2026 20:49:33 +0200 Subject: [PATCH] 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. --- .../fragments/996-acme-dns-account-01.yml | 4 ++ plugins/module_utils/_acme/certificate.py | 31 ++++++++---- plugins/module_utils/_acme/challenges.py | 20 ++++++++ plugins/modules/acme_certificate.py | 47 ++++++++++++------- .../modules/acme_certificate_order_create.py | 47 ++++++++++++++++--- .../modules/acme_certificate_order_info.py | 4 ++ .../acme_certificate_order_validate.py | 13 +++-- .../targets/acme_certificate/tasks/impl.yml | 37 +++++++++++++++ .../acme_certificate/tests/validate.yml | 13 +++++ .../targets/setup_acme/tasks/obtain-cert.yml | 4 +- 10 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 changelogs/fragments/996-acme-dns-account-01.yml diff --git a/changelogs/fragments/996-acme-dns-account-01.yml b/changelogs/fragments/996-acme-dns-account-01.yml new file mode 100644 index 00000000..224be1c2 --- /dev/null +++ b/changelogs/fragments/996-acme-dns-account-01.yml @@ -0,0 +1,4 @@ +minor_changes: + - acme_* modules - support ``dns-account-01`` challenge type according to + `acme-dns-account-label draft 02 `__ + (https://github.com/ansible-collections/community.crypto/pull/996). diff --git a/plugins/module_utils/_acme/certificate.py b/plugins/module_utils/_acme/certificate.py index bba72789..6bcec77d 100644 --- a/plugins/module_utils/_acme/certificate.py +++ b/plugins/module_utils/_acme/certificate.py @@ -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 = [] diff --git a/plugins/module_utils/_acme/challenges.py b/plugins/module_utils/_acme/challenges.py index 8d5f4550..ce7c89ec 100644 --- a/plugins/module_utils/_acme/challenges.py +++ b/plugins/module_utils/_acme/challenges.py @@ -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": diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index 78f3b2e3..0e8dd3d3 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -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"]}, diff --git a/plugins/modules/acme_certificate_order_create.py b/plugins/modules/acme_certificate_order_create.py index 994adde9..d960ce06 100644 --- a/plugins/modules/acme_certificate_order_create.py +++ b/plugins/modules/acme_certificate_order_create.py @@ -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) diff --git a/plugins/modules/acme_certificate_order_info.py b/plugins/modules/acme_certificate_order_info.py index abbd4c08..d628fe48 100644 --- a/plugins/modules/acme_certificate_order_info.py +++ b/plugins/modules/acme_certificate_order_info.py @@ -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: diff --git a/plugins/modules/acme_certificate_order_validate.py b/plugins/modules/acme_certificate_order_validate.py index 9eee2ce4..79d11ed7 100644 --- a/plugins/modules/acme_certificate_order_validate.py +++ b/plugins/modules/acme_certificate_order_validate.py @@ -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() diff --git a/tests/integration/targets/acme_certificate/tasks/impl.yml b/tests/integration/targets/acme_certificate/tasks/impl.yml index 6bde80b9..875bf1a3 100644 --- a/tests/integration/targets/acme_certificate/tasks/impl.yml +++ b/tests/integration/targets/acme_certificate/tasks/impl.yml @@ -364,6 +364,29 @@ ansible.builtin.set_fact: cert_8_obtain_results: "{{ certificate_obtain_result }}" cert_8_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" +- when: ansible_version.full is version('2.21', '>=') + block: + - name: Obtain cert 9 + ansible.builtin.include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 9 + certificate_name: cert-9 + key_type: ec256 + subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com" + subject_alt_name_critical: false + account_key: account-ec256 + challenge: dns-account-01 + modify_account: true + deactivate_authzs: false + force: false + remaining_days: 1 + terms_agreed: true + account_email: "example@example.org" + use_csr_content: true + - name: Store obtain results for cert 9 + ansible.builtin.set_fact: + cert_9_obtain_results: "{{ certificate_obtain_result }}" + cert_9_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" ## DISSECT CERTIFICATES ####################################################################### # Make sure certificates are valid. Root certificate for Pebble equals the chain certificate. @@ -401,6 +424,11 @@ ansible.builtin.command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-8-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-8-chain.pem" "{{ remote_tmp_dir }}/cert-8.pem"' ignore_errors: true register: cert_8_valid +- name: Verifying cert 9 + ansible.builtin.command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-9-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-9-chain.pem" "{{ remote_tmp_dir }}/cert-9.pem"' + ignore_errors: true + register: cert_9_valid + when: ansible_version.full is version('2.21', '>=') # Dump certificate info - name: Dumping cert 1 @@ -429,6 +457,10 @@ - name: Dumping cert 8 ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-8.pem" -noout -text' register: cert_8_text +- name: Dumping cert 9 + ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-9.pem" -noout -text' + register: cert_9_text + when: ansible_version.full is version('2.21', '>=') # Dump certificate info - name: Dumping cert 1 @@ -465,6 +497,11 @@ community.crypto.x509_certificate_info: path: "{{ remote_tmp_dir }}/cert-8.pem" register: cert_8_info +- name: Dumping cert 9 + community.crypto.x509_certificate_info: + path: "{{ remote_tmp_dir }}/cert-9.pem" + register: cert_9_info + when: ansible_version.full is version('2.21', '>=') ## GET ACCOUNT ORDERS ######################################################################### - name: Don't retrieve orders diff --git a/tests/integration/targets/acme_certificate/tests/validate.yml b/tests/integration/targets/acme_certificate/tests/validate.yml index 6b399823..21dfc36a 100644 --- a/tests/integration/targets/acme_certificate/tests/validate.yml +++ b/tests/integration/targets/acme_certificate/tests/validate.yml @@ -160,6 +160,19 @@ that: - "'IP Address:127.0.0.1' in cert_8_text.stdout or 'IP:127.0.0.1' in cert_8_text.stdout" +- when: ansible_version.full is version('2.21', '>=') + block: + - name: Check that certificate 9 is valid + ansible.builtin.assert: + that: + - cert_9_valid is not failed + - name: Check that certificate 9 contains correct SANs + ansible.builtin.assert: + that: + - "'DNS:*.example.com' in cert_9_text.stdout" + - "'DNS:example.org' in cert_9_text.stdout" + - "'DNS:t1.example.com' in cert_9_text.stdout" + - name: Validate that orders were not retrieved ansible.builtin.assert: that: diff --git a/tests/integration/targets/setup_acme/tasks/obtain-cert.yml b/tests/integration/targets/setup_acme/tasks/obtain-cert.yml index 98c9a056..e0a8464a 100644 --- a/tests/integration/targets/setup_acme/tasks/obtain-cert.yml +++ b/tests/integration/targets/setup_acme/tasks/obtain-cert.yml @@ -72,7 +72,7 @@ body_format: json body: "{{ item.value }}" with_dict: "{{ challenge_data.challenge_data_dns }}" - when: "challenge_data is changed and challenge == 'dns-01'" + when: "challenge_data is changed and challenge in ['dns-01', 'dns-account-01']" - name: ({{ certgen_title }}) Create TLS ALPN challenges (acme_challenge_cert_helper) community.crypto.acme_challenge_cert_helper: challenge: tls-alpn-01 @@ -146,7 +146,7 @@ url: "http://{{ acme_host }}:5000/dns/{{ item.key }}" method: DELETE with_dict: "{{ challenge_data.challenge_data_dns }}" - when: "challenge_data is changed and challenge == 'dns-01'" + when: "challenge_data is changed and challenge in ['dns-01', 'dns-account-01']" - name: ({{ certgen_title }}) Deleting TLS ALPN challenges ansible.builtin.uri: url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.value['tls-alpn-01'].resource }}"