Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acme_account: add support for External Account Binding #100

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- "acme_account - add ``external_account_binding`` option to allow creation of ACME accounts with External Account Binding (https:/ansible-collections/community.crypto/issues/89)."
134 changes: 126 additions & 8 deletions plugins/module_utils/acme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 []
Expand All @@ -703,8 +784,23 @@ 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:
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
}
Expand All @@ -714,6 +810,21 @@ 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'])
)
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)

Expand Down Expand Up @@ -783,7 +894,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
Expand All @@ -803,6 +916,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
'''

Expand All @@ -821,7 +938,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
Expand Down
49 changes: 48 additions & 1 deletion plugins/modules/acme_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@
- "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 = '''
Expand Down Expand Up @@ -125,6 +152,8 @@
type: str
'''

import base64

from ansible.module_utils.basic import AnsibleModule

from ansible_collections.community.crypto.plugins.module_utils.acme import (
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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.')
Expand Down
59 changes: 49 additions & 10 deletions tests/integration/targets/acme_account/tasks/impl.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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:[email protected]
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
8 changes: 8 additions & 0 deletions tests/integration/targets/acme_account/tests/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"