acme_account: add support for External Account Binding (#100)

* acme_account: add support for External Account Binding.

* Add changelog fragment.

* Error if externalAccountRequired is set in ACME directory meta, but external account data is not provided.

* Validate that EAB key is Base64URL encoded.

* Improve documentation.

* Add padding to Base64 encoded key if necessary.

* Make account creation idempotent with ZeroSSL.
pull/63/head
Felix Fontein 2020-08-16 18:00:26 +02:00 committed by GitHub
parent 2f59d44f9e
commit d03e723fe0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 233 additions and 19 deletions

View File

@ -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)."

View File

@ -38,12 +38,13 @@ from ansible_collections.community.crypto.plugins.module_utils.compat import ipa
try: try:
import cryptography import cryptography
import cryptography.hazmat.backends import cryptography.hazmat.backends
import cryptography.hazmat.primitives.serialization import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.asymmetric.rsa import cryptography.hazmat.primitives.hmac
import cryptography.hazmat.primitives.asymmetric.ec import cryptography.hazmat.primitives.asymmetric.ec
import cryptography.hazmat.primitives.asymmetric.padding 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.asymmetric.utils
import cryptography.hazmat.primitives.serialization
import cryptography.x509 import cryptography.x509
import cryptography.x509.oid import cryptography.x509.oid
from distutils.version import LooseVersion 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): 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') 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) dummy, out, dummy = module.run_command(openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True)
if key_data['type'] == 'ec': 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)), {} 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): def _sign_request_cryptography(module, payload64, protected64, key_data):
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8') 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() padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15()
hashalg = cryptography.hazmat.primitives.hashes.SHA256 hashalg = cryptography.hazmat.primitives.hashes.SHA256
signature = key_data['key_obj'].sign(sign_payload, padding, hashalg()) signature = key_data['key_obj'].sign(sign_payload, padding, hashalg())
@ -556,6 +624,13 @@ class ACMEAccount(object):
else: else:
return _sign_request_openssl(self._openssl_bin, self.module, payload64, protected64, key_data) 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): def _log(self, msg, data=None):
''' '''
Write arguments to acme.log when logging is enabled. Write arguments to acme.log when logging is enabled.
@ -683,13 +758,19 @@ class ACMEAccount(object):
self.jws_header.pop('jwk') self.jws_header.pop('jwk')
self.jws_header['kid'] = self.uri 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)``. Registers a new ACME account. Returns a pair ``(created, data)``.
Here, ``created`` is ``True`` if the account was created and Here, ``created`` is ``True`` if the account was created and
``False`` if it already existed (e.g. it was not newly created), ``False`` if it already existed (e.g. it was not newly created),
or does not exist. In case the account was created or exists, or does not exist. In case the account was created or exists,
``data`` contains the account data; otherwise, it is ``None``. ``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 https://tools.ietf.org/html/rfc8555#section-7.3
''' '''
contact = contact or [] contact = contact or []
@ -703,8 +784,23 @@ class ACMEAccount(object):
new_reg['agreement'] = agreement new_reg['agreement'] = agreement
else: else:
new_reg['agreement'] = self.directory['meta']['terms-of-service'] 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'] url = self.directory['new-reg']
else: 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 = { new_reg = {
'contact': contact 'contact': contact
} }
@ -714,6 +810,21 @@ class ACMEAccount(object):
if terms_agreed: if terms_agreed:
new_reg['termsOfServiceAgreed'] = True new_reg['termsOfServiceAgreed'] = True
url = self.directory['newAccount'] 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'])
)
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.'
)
result, info = self.send_signed_request(url, new_reg) result, info = self.send_signed_request(url, new_reg)
@ -783,7 +894,9 @@ class ACMEAccount(object):
raise ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri)) raise ModuleFailException("Error getting account data from {2}: {0} {1}".format(info['status'], result, self.uri))
return result 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, 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 as the only way (without knowing an account URI) to test if an
@ -803,6 +916,10 @@ class ACMEAccount(object):
The account URI will be stored in ``self.uri``; if it is ``None``, The account URI will be stored in ``self.uri``; if it is ``None``,
the account does not exist. 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 https://tools.ietf.org/html/rfc8555#section-7.3
''' '''
@ -821,7 +938,8 @@ class ACMEAccount(object):
contact, contact,
agreement=agreement, agreement=agreement,
terms_agreed=terms_agreed, 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: if self.module.check_mode and self.uri is None and allow_creation:
created = True created = True

View File

@ -86,6 +86,33 @@ options:
- "Mutually exclusive with C(new_account_key_src)." - "Mutually exclusive with C(new_account_key_src)."
- "Required if C(new_account_key_src) is not used and state is C(changed_key)." - "Required if C(new_account_key_src) is not used and state is C(changed_key)."
type: str 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.
- If not specified by the CA, this is probably C(HS256).
type: str
required: true
choices: [ HS256, HS384, HS512 ]
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
''' '''
EXAMPLES = ''' EXAMPLES = '''
@ -125,6 +152,8 @@ account_uri:
type: str type: str
''' '''
import base64
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme import (
@ -144,6 +173,11 @@ def main():
contact=dict(type='list', elements='str', default=[]), contact=dict(type='list', elements='str', default=[]),
new_account_key_src=dict(type='path'), new_account_key_src=dict(type='path'),
new_account_key_content=dict(type='str', no_log=True), 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( module = AnsibleModule(
argument_spec=argument_spec, argument_spec=argument_spec,
@ -163,6 +197,18 @@ def main():
) )
handle_standard_module_arguments(module, needs_acme_v2=True) 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(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: try:
account = ACMEAccount(module) account = ACMEAccount(module)
changed = False changed = False
@ -189,13 +235,14 @@ def main():
changed = True changed = True
elif state == 'present': elif state == 'present':
allow_creation = module.params.get('allow_creation') 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')] contact = [str(v) for v in module.params.get('contact')]
terms_agreed = module.params.get('terms_agreed') terms_agreed = module.params.get('terms_agreed')
external_account_binding = module.params.get('external_account_binding')
created, account_data = account.setup_account( created, account_data = account.setup_account(
contact, contact,
terms_agreed=terms_agreed, terms_agreed=terms_agreed,
allow_creation=allow_creation, allow_creation=allow_creation,
external_account_binding=external_account_binding,
) )
if account_data is None: if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.') raise ModuleFailException(msg='Account does not exist or is deactivated.')

View File

@ -1,8 +1,20 @@
- name: Generate account key - name: Generate account keys
command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem 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) - name: Parse account keys (to ease debugging some test failures)
command: openssl ec -in {{ output_dir }}/accountkey.pem -noout -text command: openssl ec -in {{ output_dir }}/{{ item }}.pem -noout -text
loop:
- accountkey
- accountkey2
- accountkey3
- accountkey4
- accountkey5
- name: Do not try to create account - name: Do not try to create account
acme_account: acme_account:
@ -153,12 +165,6 @@
contact: [] contact: []
register: account_modified_2_idempotent 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) - name: Change account key (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
@ -242,3 +248,36 @@
allow_creation: no allow_creation: no
ignore_errors: yes ignore_errors: yes
register: account_not_created_3 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

View File

@ -127,3 +127,11 @@
that: that:
- account_not_created_3 is failed - account_not_created_3 is failed
- account_not_created_3.msg == 'Account does not exist or is deactivated.' - 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"