From b67a985eca2efd01b0374a1dbbad24c1156a46af Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 9 Aug 2020 16:50:03 +0200 Subject: [PATCH 1/7] acme_account: add support for External Account Binding. --- plugins/module_utils/acme.py | 116 ++++++++++++++++-- plugins/modules/acme_account.py | 33 ++++- .../targets/acme_account/tasks/impl.yml | 59 +++++++-- .../targets/acme_account/tests/validate.yml | 8 ++ 4 files changed, 197 insertions(+), 19 deletions(-) diff --git a/plugins/module_utils/acme.py b/plugins/module_utils/acme.py index 0c13ee65d..eb715705b 100644 --- a/plugins/module_utils/acme.py +++ b/plugins/module_utils/acme.py @@ -38,12 +38,13 @@ try: import cryptography import cryptography.hazmat.backends - import cryptography.hazmat.primitives.serialization - import cryptography.hazmat.primitives.asymmetric.rsa + import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.hmac import cryptography.hazmat.primitives.asymmetric.ec import cryptography.hazmat.primitives.asymmetric.padding - import cryptography.hazmat.primitives.hashes + import cryptography.hazmat.primitives.asymmetric.rsa import cryptography.hazmat.primitives.asymmetric.utils + import cryptography.hazmat.primitives.serialization import cryptography.x509 import cryptography.x509.oid from distutils.version import LooseVersion @@ -271,9 +272,42 @@ def _parse_key_openssl(openssl_binary, module, key_file=None, key_content=None): } +def _create_mac_key_openssl(openssl_bin, module, alg, key): + if alg == 'HS256': + hashalg = 'sha256' + hashbytes = 32 + elif alg == 'HS384': + hashalg = 'sha384' + hashbytes = 48 + elif alg == 'HS512': + hashalg = 'sha512' + hashbytes = 64 + else: + raise ModuleFailException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise ModuleFailException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + 'hash': hashalg, + } + + def _sign_request_openssl(openssl_binary, module, payload64, protected64, key_data): - openssl_sign_cmd = [openssl_binary, "dgst", "-{0}".format(key_data['hash']), "-sign", key_data['key_file']] sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') + if key_data['type'] == 'hmac': + hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k']))) + cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"] + else: + cmd_postfix = ["-sign", key_data['key_file']] + openssl_sign_cmd = [openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix + dummy, out, dummy = module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True) if key_data['type'] == 'ec': @@ -403,9 +437,43 @@ def _parse_key_cryptography(module, key_file=None, key_content=None): return 'unknown key type "{0}"'.format(type(key)), {} +def _create_mac_key_cryptography(module, alg, key): + if alg == 'HS256': + hashalg = cryptography.hazmat.primitives.hashes.SHA256 + hashbytes = 32 + elif alg == 'HS384': + hashalg = cryptography.hazmat.primitives.hashes.SHA384 + hashbytes = 48 + elif alg == 'HS512': + hashalg = cryptography.hazmat.primitives.hashes.SHA512 + hashbytes = 64 + else: + raise ModuleFailException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg)) + key_bytes = base64.urlsafe_b64decode(key) + if len(key_bytes) < hashbytes: + raise ModuleFailException( + '{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes)) + return { + 'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC( + key_bytes, + hashalg(), + _cryptography_backend), + 'type': 'hmac', + 'alg': alg, + 'jwk': { + 'kty': 'oct', + 'k': key, + }, + } + + def _sign_request_cryptography(module, payload64, protected64, key_data): sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') - if isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): + if 'mac_obj' in key_data: + mac = key_data['mac_obj']() + mac.update(sign_payload) + signature = mac.finalize() + elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey): padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15() hashalg = cryptography.hazmat.primitives.hashes.SHA256 signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) @@ -556,6 +624,13 @@ def sign_request(self, protected, payload, key_data, encode_payload=True): else: return _sign_request_openssl(self._openssl_bin, self.module, payload64, protected64, key_data) + def _create_mac_key(self, alg, key): + '''Create a MAC key.''' + if HAS_CURRENT_CRYPTOGRAPHY: + return _create_mac_key_cryptography(self.module, alg, key) + else: + return _create_mac_key_openssl(self._openssl_bin, self.module, alg, key) + def _log(self, msg, data=None): ''' Write arguments to acme.log when logging is enabled. @@ -683,13 +758,19 @@ def set_account_uri(self, uri): self.jws_header.pop('jwk') self.jws_header['kid'] = self.uri - def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True): + def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, + external_account_binding=None): ''' Registers a new ACME account. Returns a pair ``(created, data)``. Here, ``created`` is ``True`` if the account was created and ``False`` if it already existed (e.g. it was not newly created), or does not exist. In case the account was created or exists, ``data`` contains the account data; otherwise, it is ``None``. + + If specified, ``external_account_binding`` should be a dictionary + with keys ``kid``, ``alg`` and ``key`` + (https://tools.ietf.org/html/rfc8555#section-7.3.4). + https://tools.ietf.org/html/rfc8555#section-7.3 ''' contact = contact or [] @@ -703,6 +784,8 @@ def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creat new_reg['agreement'] = agreement else: new_reg['agreement'] = self.directory['meta']['terms-of-service'] + if external_account_binding is not None: + raise ModuleFailException('External account binding is not supported for ACME v1') url = self.directory['new-reg'] else: new_reg = { @@ -714,6 +797,16 @@ def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creat if terms_agreed: new_reg['termsOfServiceAgreed'] = True url = self.directory['newAccount'] + if external_account_binding is not None: + new_reg['externalAccountBinding'] = self.sign_request( + { + 'alg': external_account_binding['alg'], + 'kid': external_account_binding['kid'], + 'url': url, + }, + self.jwk, + self._create_mac_key(external_account_binding['alg'], external_account_binding['key']) + ) result, info = self.send_signed_request(url, new_reg) @@ -783,7 +876,9 @@ def get_account_data(self): raise ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri)) return result - def setup_account(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True, remove_account_uri_if_not_exists=False): + def setup_account(self, contact=None, agreement=None, terms_agreed=False, + allow_creation=True, remove_account_uri_if_not_exists=False, + external_account_binding=None): ''' Detect or create an account on the ACME server. For ACME v1, as the only way (without knowing an account URI) to test if an @@ -803,6 +898,10 @@ def setup_account(self, contact=None, agreement=None, terms_agreed=False, allow_ The account URI will be stored in ``self.uri``; if it is ``None``, the account does not exist. + If specified, ``external_account_binding`` should be a dictionary + with keys ``kid``, ``alg`` and ``key`` + (https://tools.ietf.org/html/rfc8555#section-7.3.4). + https://tools.ietf.org/html/rfc8555#section-7.3 ''' @@ -821,7 +920,8 @@ def setup_account(self, contact=None, agreement=None, terms_agreed=False, allow_ contact, agreement=agreement, terms_agreed=terms_agreed, - allow_creation=allow_creation and not self.module.check_mode + allow_creation=allow_creation and not self.module.check_mode, + external_account_binding=external_account_binding, ) if self.module.check_mode and self.uri is None and allow_creation: created = True diff --git a/plugins/modules/acme_account.py b/plugins/modules/acme_account.py index 9cffc4a06..ead54163f 100644 --- a/plugins/modules/acme_account.py +++ b/plugins/modules/acme_account.py @@ -86,6 +86,31 @@ - "Mutually exclusive with C(new_account_key_src)." - "Required if C(new_account_key_src) is not used and state is C(changed_key)." type: str + external_account_binding: + description: + - Allows to provide external account binding data during account creation. + - This is used by CAs like Sectigo to bind a new ACME account to an existing CA-specific + account, to be able to properly identify a customer. + - Only used when creating a new account. Can not be specified for ACME v1. + type: dict + suboptions: + kid: + description: + - The key identifier provided by the CA. + type: str + required: true + alg: + description: + - The MAC algorithm provided by the CA. + type: str + required: true + choices: [ HS256, HS384, HS512 ] + key: + description: + - The MAC key provided by the CA. + type: str + required: true + version_added: 1.1.0 ''' EXAMPLES = ''' @@ -144,6 +169,11 @@ def main(): contact=dict(type='list', elements='str', default=[]), new_account_key_src=dict(type='path'), new_account_key_content=dict(type='str', no_log=True), + external_account_binding=dict(type='dict', options=dict( + kid=dict(type='str', required=True), + alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']), + key=dict(type='str', required=True, no_log=True), + )) )) module = AnsibleModule( argument_spec=argument_spec, @@ -189,13 +219,14 @@ def main(): changed = True elif state == 'present': allow_creation = module.params.get('allow_creation') - # Make sure contact is a list of strings (unfortunately, Ansible doesn't do that for us) contact = [str(v) for v in module.params.get('contact')] terms_agreed = module.params.get('terms_agreed') + external_account_binding = module.params.get('external_account_binding') created, account_data = account.setup_account( contact, terms_agreed=terms_agreed, allow_creation=allow_creation, + external_account_binding=external_account_binding, ) if account_data is None: raise ModuleFailException(msg='Account does not exist or is deactivated.') diff --git a/tests/integration/targets/acme_account/tasks/impl.yml b/tests/integration/targets/acme_account/tasks/impl.yml index 3cd10c479..dfacd9317 100644 --- a/tests/integration/targets/acme_account/tasks/impl.yml +++ b/tests/integration/targets/acme_account/tasks/impl.yml @@ -1,8 +1,20 @@ -- name: Generate account key - command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem +- name: Generate account keys + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/{{ item }}.pem + loop: + - accountkey + - accountkey2 + - accountkey3 + - accountkey4 + - accountkey5 -- name: Parse account key (to ease debugging some test failures) - command: openssl ec -in {{ output_dir }}/accountkey.pem -noout -text +- name: Parse account keys (to ease debugging some test failures) + command: openssl ec -in {{ output_dir }}/{{ item }}.pem -noout -text + loop: + - accountkey + - accountkey2 + - accountkey3 + - accountkey4 + - accountkey5 - name: Do not try to create account acme_account: @@ -153,12 +165,6 @@ contact: [] register: account_modified_2_idempotent -- name: Generate new account key - command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/accountkey2.pem - -- name: Parse account key (to ease debugging some test failures) - command: openssl ec -in {{ output_dir }}/accountkey2.pem -noout -text - - name: Change account key (check mode, diff) acme_account: select_crypto_backend: "{{ select_crypto_backend }}" @@ -242,3 +248,36 @@ allow_creation: no ignore_errors: yes register: account_not_created_3 + +- name: Create account with External Account Binding + acme_account: + select_crypto_backend: "{{ select_crypto_backend }}" + account_key_src: "{{ output_dir }}/{{ item.account }}.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + state: present + allow_creation: yes + terms_agreed: yes + contact: + - mailto:example@example.org + external_account_binding: + kid: "{{ item.kid }}" + alg: "{{ item.alg }}" + key: "{{ item.key }}" + register: account_created_eab + ignore_errors: yes + loop: + - account: accountkey3 + kid: kid-1 + alg: HS256 + key: zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W + - account: accountkey4 + kid: kid-2 + alg: HS384 + key: b10lLJs8l1GPIzsLP0s6pMt8O0XVGnfTaCeROxQM0BIt2XrJMDHJZBM5NuQmQJQH + - account: accountkey5 + kid: kid-3 + alg: HS512 + key: zWNDZM6eQGHWpSRTPal5eIUYFTu7EajVIoguysqZ9wG44nMEtx3MUAsUDkMTQ12W +- debug: var=account_created_eab diff --git a/tests/integration/targets/acme_account/tests/validate.yml b/tests/integration/targets/acme_account/tests/validate.yml index 3bc1d7aa7..95140f131 100644 --- a/tests/integration/targets/acme_account/tests/validate.yml +++ b/tests/integration/targets/acme_account/tests/validate.yml @@ -127,3 +127,11 @@ that: - account_not_created_3 is failed - account_not_created_3.msg == 'Account does not exist or is deactivated.' + +- name: Validate that the account with External Account Binding has been created + assert: + that: + - account_created_eab.results[0] is changed + - account_created_eab.results[1] is changed + - account_created_eab.results[2] is failed + - "'HS512 key must be at least 64 bytes long' in account_created_eab.results[2].msg" From 7565a67a70f24a31833ec1cfae6cc23f5943ce09 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 9 Aug 2020 16:51:53 +0200 Subject: [PATCH 2/7] Add changelog fragment. --- .../fragments/100-acme-account-external-account-binding.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/100-acme-account-external-account-binding.yml diff --git a/changelogs/fragments/100-acme-account-external-account-binding.yml b/changelogs/fragments/100-acme-account-external-account-binding.yml new file mode 100644 index 000000000..3310fcb9a --- /dev/null +++ b/changelogs/fragments/100-acme-account-external-account-binding.yml @@ -0,0 +1,2 @@ +minor_changes: +- "acme_account - add ``external_account_binding`` option to allow creation of ACME accounts with External Account Binding (https://github.com/ansible-collections/community.crypto/issues/89)." From 67abe960c893235a057baf95e3f2e3785dbfc320 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 15 Aug 2020 22:26:10 +0200 Subject: [PATCH 3/7] Error if externalAccountRequired is set in ACME directory meta, but external account data is not provided. --- plugins/module_utils/acme.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/module_utils/acme.py b/plugins/module_utils/acme.py index eb715705b..ae2af0069 100644 --- a/plugins/module_utils/acme.py +++ b/plugins/module_utils/acme.py @@ -807,6 +807,11 @@ def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creat self.jwk, self._create_mac_key(external_account_binding['alg'], external_account_binding['key']) ) + elif self.directory['meta'].get('externalAccountRequired'): + raise ModuleFailException( + 'To create an account, an external account binding must be specified. ' + 'Use the acme_account module with the external_account_binding option.' + ) result, info = self.send_signed_request(url, new_reg) From 57247d31f645e8bb496741179e2453899aa04f26 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 15 Aug 2020 22:26:43 +0200 Subject: [PATCH 4/7] Validate that EAB key is Base64URL encoded. --- plugins/modules/acme_account.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/modules/acme_account.py b/plugins/modules/acme_account.py index ead54163f..dcd106fcf 100644 --- a/plugins/modules/acme_account.py +++ b/plugins/modules/acme_account.py @@ -150,6 +150,8 @@ type: str ''' +import base64 + from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.crypto.plugins.module_utils.acme import ( @@ -193,6 +195,13 @@ def main(): ) handle_standard_module_arguments(module, needs_acme_v2=True) + if module.params['external_account_binding']: + # Make sure key is Base64 encoded + try: + base64.urlsafe_b64decode(module.params['external_account_binding']['key']) + except Exception as e: + module.fail_json(msg='Key for external_account_binding must be Base64 URL encoded (%s)' % e) + try: account = ACMEAccount(module) changed = False From 059da9097776abe0bd4cd8a2a13efeda96f63758 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 15 Aug 2020 22:27:08 +0200 Subject: [PATCH 5/7] Improve documentation. --- plugins/modules/acme_account.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/modules/acme_account.py b/plugins/modules/acme_account.py index dcd106fcf..80cc6c60d 100644 --- a/plugins/modules/acme_account.py +++ b/plugins/modules/acme_account.py @@ -102,12 +102,13 @@ alg: description: - The MAC algorithm provided by the CA. + - If not specified by the CA, this is probably C(HS256). type: str required: true choices: [ HS256, HS384, HS512 ] key: description: - - The MAC key provided by the CA. + - Base64 URL encoded value of the MAC key provided by the CA. type: str required: true version_added: 1.1.0 From 10498072ed7c028392cca5331259f29e9eb1b45a Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 15 Aug 2020 22:51:13 +0200 Subject: [PATCH 6/7] Add padding to Base64 encoded key if necessary. --- plugins/modules/acme_account.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/modules/acme_account.py b/plugins/modules/acme_account.py index 80cc6c60d..1d538e3b2 100644 --- a/plugins/modules/acme_account.py +++ b/plugins/modules/acme_account.py @@ -109,6 +109,7 @@ key: description: - Base64 URL encoded value of the MAC key provided by the CA. + - Padding (C(=) symbols at the end) can be omitted. type: str required: true version_added: 1.1.0 @@ -197,11 +198,16 @@ def main(): handle_standard_module_arguments(module, needs_acme_v2=True) if module.params['external_account_binding']: + # Make sure padding is there + key = module.params['external_account_binding']['key'] + if len(key) % 4 != 0: + key = key + ('=' * (4 - (len(key) % 4))) # Make sure key is Base64 encoded try: - base64.urlsafe_b64decode(module.params['external_account_binding']['key']) + base64.urlsafe_b64decode(key) except Exception as e: module.fail_json(msg='Key for external_account_binding must be Base64 URL encoded (%s)' % e) + module.params['external_account_binding']['key'] = key try: account = ACMEAccount(module) From 147e70b57e9089345b3fcaa4786aa8d64b168bbc Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 15 Aug 2020 23:22:58 +0200 Subject: [PATCH 7/7] Make account creation idempotent with ZeroSSL. --- plugins/module_utils/acme.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/module_utils/acme.py b/plugins/module_utils/acme.py index ae2af0069..9ac24076e 100644 --- a/plugins/module_utils/acme.py +++ b/plugins/module_utils/acme.py @@ -788,6 +788,19 @@ def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creat raise ModuleFailException('External account binding is not supported for ACME v1') url = self.directory['new-reg'] else: + if (external_account_binding is not None or self.directory['meta'].get('externalAccountRequired')) and allow_creation: + # Some ACME servers such as ZeroSSL do not like it when you try to register an existing account + # and provide external_account_binding credentials. Thus we first send a request with allow_creation=False + # to see whether the account already exists. + + # Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even + # if onlyReturnExisting is set to true. + created, data = self._new_reg(contact=contact, allow_creation=False) + if data: + # An account already exists! Return data + return created, data + # An account does not yet exist. Try to create one next. + new_reg = { 'contact': contact } @@ -807,7 +820,7 @@ def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creat self.jwk, self._create_mac_key(external_account_binding['alg'], external_account_binding['key']) ) - elif self.directory['meta'].get('externalAccountRequired'): + elif self.directory['meta'].get('externalAccountRequired') and allow_creation: raise ModuleFailException( 'To create an account, an external account binding must be specified. ' 'Use the acme_account module with the external_account_binding option.'