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
parent
2f59d44f9e
commit
d03e723fe0
|
@ -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)."
|
|
@ -38,12 +38,13 @@ from ansible_collections.community.crypto.plugins.module_utils.compat import ipa
|
|||
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 @@ class ACMEAccount(object):
|
|||
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 @@ class ACMEAccount(object):
|
|||
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,8 +784,23 @@ class ACMEAccount(object):
|
|||
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:
|
||||
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
|
||||
}
|
||||
|
@ -714,6 +810,21 @@ class ACMEAccount(object):
|
|||
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'])
|
||||
)
|
||||
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)
|
||||
|
||||
|
@ -783,7 +894,9 @@ class ACMEAccount(object):
|
|||
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 +916,10 @@ class ACMEAccount(object):
|
|||
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 +938,8 @@ class ACMEAccount(object):
|
|||
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
|
||||
|
|
|
@ -86,6 +86,33 @@ options:
|
|||
- "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.
|
||||
- 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 = '''
|
||||
|
@ -125,6 +152,8 @@ account_uri:
|
|||
type: str
|
||||
'''
|
||||
|
||||
import base64
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
|
@ -144,6 +173,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,
|
||||
|
@ -163,6 +197,18 @@ 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(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)
|
||||
changed = False
|
||||
|
@ -189,13 +235,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.')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue