diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c96cb42022..def2702256 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1345,7 +1345,7 @@ files: <<: *docker support: community test/units/module_utils/facts/network/test_generic_bsd.py: *bsd - test/units/module_utils/test_acme.py: *crypto + test/units/module_utils/acme: *crypto test/units/module_utils/xenserver/: bvitnik test/units/modules/cloud/docker: <<: *docker diff --git a/changelogs/fragments/53660-acme_certificate-ip-identifiers.yml b/changelogs/fragments/53660-acme_certificate-ip-identifiers.yml new file mode 100644 index 0000000000..0ffb95a2d4 --- /dev/null +++ b/changelogs/fragments/53660-acme_certificate-ip-identifiers.yml @@ -0,0 +1,2 @@ +minor_changes: +- "acme_certificate - add experimental support for IP address identifiers." diff --git a/lib/ansible/module_utils/acme.py b/lib/ansible/module_utils/acme.py index 19ea0cddc9..45fb836cf2 100644 --- a/lib/ansible/module_utils/acme.py +++ b/lib/ansible/module_utils/acme.py @@ -822,21 +822,97 @@ class ACMEAccount(object): return True, account_data -def cryptography_get_csr_domains(module, csr_filename): +def _normalize_ip(ip): + if ':' not in ip: + # For IPv4 addresses: remove trailing zeros per nibble + ip = '.'.join([nibble.lstrip('0') or '0' for nibble in ip.split('.')]) + return ip + # For IPv6 addresses: + # 1. Make them lowercase and split + ip = ip.lower() + i = ip.find('::') + if i >= 0: + front = ip[:i].split(':') or [] + back = ip[i + 2:].split(':') or [] + ip = front + ['0'] * (8 - len(front) - len(back)) + back + else: + ip = ip.split(':') + # 2. Remove trailing zeros per nibble + ip = [nibble.lstrip('0') or '0' for nibble in ip] + # 3. Find longest consecutive sequence of zeros + zeros_start = -1 + zeros_length = -1 + current_start = -1 + for i, nibble in enumerate(ip): + if nibble == '0': + if current_start < 0: + current_start = i + elif current_start >= 0: + if i - current_start > zeros_length: + zeros_start = current_start + zeros_length = i - current_start + current_start = -1 + if current_start >= 0: + if 8 - current_start > zeros_length: + zeros_start = current_start + zeros_length = 8 - current_start + # 4. If the sequence has at least two elements, contract + if zeros_length >= 2: + return ':'.join(ip[:zeros_start]) + '::' + ':'.join(ip[zeros_start + zeros_length:]) + # 5. If not, return full IP + return ':'.join(ip) + + +def openssl_get_csr_identifiers(openssl_binary, module, csr_filename): ''' - Return a set of requested domains (CN and SANs) for the CSR. + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. ''' - domains = set([]) + openssl_csr_cmd = [openssl_binary, "req", "-in", csr_filename, "-noout", "-text"] + dummy, out, dummy = module.run_command(openssl_csr_cmd, check_rc=True) + + identifiers = set([]) + common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) + if common_name is not None: + identifiers.add(('dns', common_name.group(1))) + subject_alt_names = re.search( + r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", + to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.lower().startswith("dns:"): + identifiers.add(('dns', san[4:])) + elif san.lower().startswith("ip:"): + identifiers.add(('ip', _normalize_ip(san[3:]))) + elif san.lower().startswith("ip address:"): + identifiers.add(('ip', _normalize_ip(san[11:]))) + else: + raise ModuleFailException('Found unsupported SAN identifier "{0}"'.format(san)) + return identifiers + + +def cryptography_get_csr_identifiers(module, csr_filename): + ''' + Return a set of requested identifiers (CN and SANs) for the CSR. + Each identifier is a pair (type, identifier), where type is either + 'dns' or 'ip'. + ''' + identifiers = set([]) csr = cryptography.x509.load_pem_x509_csr(read_file(csr_filename), _cryptography_backend) for sub in csr.subject: if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME: - domains.add(sub.value) + identifiers.add(('dns', sub.value)) for extension in csr.extensions: if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: for name in extension.value: if isinstance(name, cryptography.x509.DNSName): - domains.add(name.value) - return domains + identifiers.add(('dns', name.value)) + elif isinstance(name, cryptography.x509.IPAddress): + identifiers.add(('ip', _normalize_ip(str(name.value)))) + else: + raise ModuleFailException('Found unsupported SAN identifier {0}'.format(name)) + return identifiers def cryptography_get_cert_days(module, cert_file, now=None): diff --git a/lib/ansible/modules/crypto/acme/acme_certificate.py b/lib/ansible/modules/crypto/acme/acme_certificate.py index aeb6dd7414..f3bac41022 100644 --- a/lib/ansible/modules/crypto/acme/acme_certificate.py +++ b/lib/ansible/modules/crypto/acme/acme_certificate.py @@ -39,6 +39,8 @@ description: L(the main ACME specification,https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-8) and the L(TLS-ALPN-01 specification,https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3). Also, consider the examples provided for this module." + - "The module includes experimental support for IP identifiers according to + the L(current ACME IP draft,https://tools.ietf.org/html/draft-ietf-acme-ip-05)." notes: - "At least one of C(dest) and C(fullchain_dest) must be specified." - "This module includes basic account management functionality. @@ -298,19 +300,27 @@ EXAMPLES = r''' RETURN = ''' cert_days: - description: the number of days the certificate remains valid. + description: The number of days the certificate remains valid. returned: success type: int challenge_data: - description: per domain / challenge type challenge data + description: Per identifier / challenge type challenge data. returned: changed type: complex contains: resource: - description: the challenge resource that must be created for validation + description: The challenge resource that must be created for validation. returned: changed type: str sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA + resource_original: + description: + - The original challenge resource including type identifier for C(tls-alpn-01) + challenges. + returned: changed and challenge is C(tls-alpn-01) + type: str + sample: DNS:example.com + version_added: "2.8" resource_value: description: - The value the resource has to produce for the validation. @@ -325,13 +335,13 @@ challenge_data: type: str sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA record: - description: the full DNS record's name for the challenge + description: The full DNS record's name for the challenge. returned: changed and challenge is C(dns-01) type: str sample: _acme-challenge.example.com version_added: "2.5" challenge_data_dns: - description: list of TXT values per DNS record, in case challenge is C(dns-01) + description: List of TXT values per DNS record, in case challenge is C(dns-01). returned: changed type: dict version_added: "2.5" @@ -362,8 +372,13 @@ account_uri: ''' from ansible.module_utils.acme import ( - ModuleFailException, write_file, nopad_b64, pem_to_der, ACMEAccount, - HAS_CURRENT_CRYPTOGRAPHY, cryptography_get_csr_domains, cryptography_get_cert_days, + ModuleFailException, + write_file, nopad_b64, pem_to_der, + ACMEAccount, + HAS_CURRENT_CRYPTOGRAPHY, + cryptography_get_csr_identifiers, + openssl_get_csr_identifiers, + cryptography_get_cert_days, set_crypto_backend, ) @@ -377,7 +392,7 @@ import time from datetime import datetime from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils._text import to_text, to_bytes +from ansible.module_utils._text import to_bytes def get_cert_days(module, cert_file): @@ -454,49 +469,37 @@ class ACMEClient(object): # signed ACME request. pass - # Extract list of domains from CSR if not os.path.exists(self.csr): raise ModuleFailException("CSR %s not found" % (self.csr)) self._openssl_bin = module.get_bin_path('openssl', True) - self.domains = self._get_csr_domains() - def _get_csr_domains(self): + # Extract list of identifiers from CSR + self.identifiers = self._get_csr_identifiers() + + def _get_csr_identifiers(self): ''' - Parse the CSR and return the list of requested domains + Parse the CSR and return the list of requested identifiers ''' if HAS_CURRENT_CRYPTOGRAPHY: - return cryptography_get_csr_domains(self.module, self.csr) - openssl_csr_cmd = [self._openssl_bin, "req", "-in", self.csr, "-noout", "-text"] - dummy, out, dummy = self.module.run_command(openssl_csr_cmd, check_rc=True) + return cryptography_get_csr_identifiers(self.module, self.csr) + else: + return openssl_get_csr_identifiers(self._openssl_bin, self.module, self.csr) - domains = set([]) - common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict')) - if common_name is not None: - domains.add(common_name.group(1)) - subject_alt_names = re.search( - r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", - to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL) - if subject_alt_names is not None: - for san in subject_alt_names.group(1).split(", "): - if san.startswith("DNS:"): - domains.add(san[4:]) - return domains - - def _add_or_update_auth(self, domain, auth): + def _add_or_update_auth(self, identifier_type, identifier, auth): ''' Add or update the given authroization in the global authorizations list. Return True if the auth was updated/added and False if no change was necessary. ''' - if self.authorizations.get(domain) == auth: + if self.authorizations.get(identifier_type + ':' + identifier) == auth: return False - self.authorizations[domain] = auth + self.authorizations[identifier_type + ':' + identifier] = auth return True - def _new_authz_v1(self, domain): + def _new_authz_v1(self, identifier_type, identifier): ''' - Create a new authorization for the given domain. + Create a new authorization for the given identifier. Return the authorization object of the new authorization https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4 ''' @@ -505,7 +508,7 @@ class ACMEClient(object): new_authz = { "resource": "new-authz", - "identifier": {"type": "dns", "value": domain}, + "identifier": {"type": identifier_type, "value": identifier}, } result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz) @@ -515,7 +518,7 @@ class ACMEClient(object): result['uri'] = info['location'] return result - def _get_challenge_data(self, auth, domain): + def _get_challenge_data(self, auth, identifier_type, identifier): ''' Returns a dict with the data for all proposed (and supported) challenges of the given authorization. @@ -526,31 +529,55 @@ class ACMEClient(object): # is not responsible for fulfilling the challenges. Calculate # and return the required information for each challenge. for challenge in auth['challenges']: - type = challenge['type'] + challenge_type = challenge['type'] token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) keyauthorization = self.account.get_keyauthorization(token) - if type == 'http-01': + if challenge_type == 'http-01': # https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-8.3 resource = '.well-known/acme-challenge/' + token - data[type] = {'resource': resource, 'resource_value': keyauthorization} - elif type == 'dns-01': + data[challenge_type] = {'resource': resource, 'resource_value': keyauthorization} + elif challenge_type == 'dns-01': + if identifier_type != 'dns': + continue # https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-8.4 resource = '_acme-challenge' value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest()) - record = (resource + domain[1:]) if domain.startswith('*.') else (resource + '.' + domain) - data[type] = {'resource': resource, 'resource_value': value, 'record': record} - elif type == 'tls-alpn-01': + record = (resource + identifier[1:]) if identifier.startswith('*.') else (resource + '.' + identifier) + data[challenge_type] = {'resource': resource, 'resource_value': value, 'record': record} + elif challenge_type == 'tls-alpn-01': # https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05#section-3 - resource = domain + if identifier_type == 'ip': + if ':' in identifier: + # IPv6 address: use reverse IP6.ARPA mapping (RFC3596) + i = identifier.find('::') + if i >= 0: + nibbles = [nibble for nibble in identifier[:i].split(':') if nibble] + suffix = [nibble for nibble in identifier[i + 1:].split(':') if nibble] + if len(nibbles) + len(suffix) < 8: + nibbles.extend(['0'] * (8 - len(nibbles) - len(suffix))) + nibbles.extend(suffix) + else: + nibbles = identifier.split(':') + resource = [] + for nibble in reversed(nibbles): + nibble = '0' * (4 - len(nibble)) + nibble.lower() + for octet in reversed(nibble): + resource.append(octet) + resource = '.'.join(resource) + '.ip6.arpa.' + else: + # IPv4 address: use reverse IN-ADDR.ARPA mapping (RFC1034) + resource = '.'.join(reversed(identifier.split('.'))) + '.in-addr.arpa.' + else: + resource = identifier value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest()) - data[type] = {'resource': resource, 'resource_value': value} + data[challenge_type] = {'resource': resource, 'resource_original': identifier_type + ':' + identifier, 'resource_value': value} else: continue return data - def _fail_challenge(self, domain, auth, error): + def _fail_challenge(self, identifier_type, identifier, auth, error): ''' Aborts with a specific error for a challenge. ''' @@ -564,9 +591,9 @@ class ACMEClient(object): error_details += ' DETAILS: {0};'.format(challenge['error']['detail']) else: error_details += ';' - raise ModuleFailException("{0}: {1}".format(error.format(domain), error_details)) + raise ModuleFailException("{0}: {1}".format(error.format(identifier_type + ':' + identifier), error_details)) - def _validate_challenges(self, domain, auth): + def _validate_challenges(self, identifier_type, identifier, auth): ''' Validate the authorization provided in the auth dict. Returns True when the validation was successful and False when it was not. @@ -592,7 +619,7 @@ class ACMEClient(object): while status not in ['valid', 'invalid', 'revoked']: result, dummy = self.account.get_request(auth['uri']) result['uri'] = auth['uri'] - if self._add_or_update_auth(domain, result): + if self._add_or_update_auth(identifier_type, identifier, result): self.changed = True # https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2 # "status (required, string): ... @@ -604,7 +631,7 @@ class ACMEClient(object): time.sleep(2) if status == 'invalid': - self._fail_challenge(domain, result, 'Authorization for {0} returned invalid') + self._fail_challenge(identifier_type, identifier, result, 'Authorization for {0} returned invalid') return status == 'valid' @@ -717,10 +744,10 @@ class ACMEClient(object): https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.4 ''' identifiers = [] - for domain in self.domains: + for identifier_type, identifier in self.identifiers: identifiers.append({ - 'type': 'dns', - 'value': domain, + 'type': identifier_type, + 'value': identifier, }) new_order = { "identifiers": identifiers @@ -733,10 +760,11 @@ class ACMEClient(object): for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri - domain = auth_data['identifier']['value'] + identifier_type = auth_data['identifier']['type'] + identifier = auth_data['identifier']['value'] if auth_data.get('wildcard', False): - domain = '*.{0}'.format(domain) - self.authorizations[domain] = auth_data + identifier = '*.{0}'.format(identifier) + self.authorizations[identifier_type + ':' + identifier] = auth_data self.order_uri = info['location'] self.finalize_uri = result['finalize'] @@ -758,14 +786,17 @@ class ACMEClient(object): def start_challenges(self): ''' - Create new authorizations for all domains of the CSR, + Create new authorizations for all identifiers of the CSR, respectively start a new order for ACME v2. ''' self.authorizations = {} if self.version == 1: - for domain in self.domains: - new_auth = self._new_authz_v1(domain) - self._add_or_update_auth(domain, new_auth) + for identifier_type, identifier in self.identifiers: + if identifier_type != 'dns': + raise ModuleFailException('ACME v1 only supports DNS identifiers!') + for identifier_type, identifier in self.identifiers: + new_auth = self._new_authz_v1(identifier_type, identifier) + self._add_or_update_auth(identifier_type, identifier, new_auth) else: self._new_order_v2() self.changed = True @@ -777,12 +808,14 @@ class ACMEClient(object): ''' # Get general challenge data data = {} - for domain, auth in self.authorizations.items(): - data[domain] = self._get_challenge_data(self.authorizations[domain], domain) + for type_identifier, auth in self.authorizations.items(): + identifier_type, identifier = type_identifier.split(':', 1) + # We drop the type from the key to preserve backwards compatibility + data[identifier] = self._get_challenge_data(self.authorizations[type_identifier], identifier_type, identifier) # Get DNS challenge data data_dns = {} if self.challenge == 'dns-01': - for domain, challenges in data.items(): + for identifier, challenges in data.items(): if self.challenge in challenges: values = data_dns.get(challenges[self.challenge]['record']) if values is None: @@ -793,7 +826,7 @@ class ACMEClient(object): def finish_challenges(self): ''' - Verify challenges for all domains of the CSR. + Verify challenges for all identifiers of the CSR. ''' self.authorizations = {} @@ -801,9 +834,9 @@ class ACMEClient(object): if self.version == 1: # For ACME v1, we attempt to create new authzs. Existing ones # will be returned instead. - for domain in self.domains: - new_auth = self._new_authz_v1(domain) - self._add_or_update_auth(domain, new_auth) + for identifier_type, identifier in self.identifiers: + new_auth = self._new_authz_v1(identifier_type, identifier) + self._add_or_update_auth(identifier_type, identifier, new_auth) else: # For ACME v2, we obtain the order object by fetching the # order URI, and extract the information from there. @@ -818,17 +851,19 @@ class ACMEClient(object): for auth_uri in result['authorizations']: auth_data, dummy = self.account.get_request(auth_uri) auth_data['uri'] = auth_uri - domain = auth_data['identifier']['value'] + identifier_type = auth_data['identifier']['type'] + identifier = auth_data['identifier']['value'] if auth_data.get('wildcard', False): - domain = '*.{0}'.format(domain) - self.authorizations[domain] = auth_data + identifier = '*.{0}'.format(identifier) + self.authorizations[identifier_type + ':' + identifier] = auth_data self.finalize_uri = result['finalize'] # Step 2: validate challenges - for domain, auth in self.authorizations.items(): + for type_identifier, auth in self.authorizations.items(): if auth['status'] == 'pending': - self._validate_challenges(domain, auth) + identifier_type, identifier = type_identifier.split(':', 1) + self._validate_challenges(identifier_type, identifier, auth) def get_certificate(self): ''' @@ -836,14 +871,14 @@ class ACMEClient(object): First verifies whether all authorizations are valid; if not, aborts with an error. ''' - for domain in self.domains: - auth = self.authorizations.get(domain) + for identifier_type, identifier in self.identifiers: + auth = self.authorizations.get(identifier_type + ':' + identifier) if auth is None: - raise ModuleFailException('Found no authorization information for "{0}"!'.format(domain)) + raise ModuleFailException('Found no authorization information for "{0}"!'.format(identifier_type + ':' + identifier)) if 'status' not in auth: - self._fail_challenge(domain, auth, 'Authorization for {0} returned no status') + self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned no status') if auth['status'] != 'valid': - self._fail_challenge(domain, auth, 'Authorization for {0} returned status ' + str(auth['status'])) + self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status'])) if self.version == 1: cert = self._new_cert_v1() @@ -879,8 +914,8 @@ class ACMEClient(object): if self.version == 1: authz_deactivate['resource'] = 'authz' if self.authorizations: - for domain in self.domains: - auth = self.authorizations.get(domain) + for identifier_type, identifier in self.identifiers: + auth = self.authorizations.get(identifier_type + ':' + identifier) if auth is None or auth.get('status') != 'valid': continue try: @@ -968,9 +1003,13 @@ def main(): if module.params['deactivate_authzs']: client.deactivate_authzs() data, data_dns = client.get_challenges_data() + auths = dict() + for k, v in client.authorizations.items(): + # Remove "type:" from key + auths[k.split(':', 1)[1]] = v module.exit_json( changed=client.changed, - authorizations=client.authorizations, + authorizations=auths, finalize_uri=client.finalize_uri, order_uri=client.order_uri, account_uri=client.account.uri, diff --git a/test/units/module_utils/acme/fixtures/cert_1.pem b/test/units/module_utils/acme/fixtures/cert_1.pem new file mode 100644 index 0000000000..bb4aca5197 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/cert_1.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBljCCATugAwIBAgIBATAKBggqhkjOPQQDAjAWMRQwEgYDVQQDEwthbnNpYmxl +LmNvbTAeFw0xODExMjUxNTI4MjNaFw0xODExMjYxNTI4MjRaMBYxFDASBgNVBAMT +C2Fuc2libGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAJz0yAAXAwEm +OhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8S +LzE44/Epm6N6MHgwIwYDVR0RBBwwGoILZXhhbXBsZS5jb22CC2V4YW1wbGUub3Jn +MAwGA1UdEwEB/wQCMAAwDwYDVR0PAQH/BAUDAweAADATBgNVHSUEDDAKBggrBgEF +BQcDATAdBgNVHQ4EFgQUmNL9PMzNaUX74owwLFRiGDS3B3MwCgYIKoZIzj0EAwID +SQAwRgIhALz7Ur96ky0OfM5D9MwFmCg2jccqm/UglGI9+4KeOEIyAiEAwFX4tdll +QSrd1HY/jMsHwdK5wH3JkK/9+fGwyRP11VI= +-----END CERTIFICATE----- diff --git a/test/units/module_utils/acme/fixtures/csr_1.pem b/test/units/module_utils/acme/fixtures/csr_1.pem new file mode 100644 index 0000000000..8fc37c40c6 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/csr_1.pem @@ -0,0 +1,9 @@ +-----BEGIN NEW CERTIFICATE REQUEST----- +MIIBJTCBzQIBADAWMRQwEgYDVQQDEwthbnNpYmxlLmNvbTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABACc9MgAFwMBJjoU0ZI18cIHnW1juoKG2DN5VrM60uvBvEEs +4V0egJkNyM2Q4pp001zu14VcpQ0/Ei8xOOPxKZugVTBTBgkqhkiG9w0BCQ4xRjBE +MCMGA1UdEQQcMBqCC2V4YW1wbGUuY29tggtleGFtcGxlLm9yZzAMBgNVHRMBAf8E +AjAAMA8GA1UdDwEB/wQFAwMHgAAwCgYIKoZIzj0EAwIDRwAwRAIgcDyoRmwFVBDl +FvbFZtiSd5wmJU1ltM6JtcfnLWnjY54CICruOByrropFUkOKKb4xXOYsgaDT93Wr +URnCJfTLr2T3 +-----END NEW CERTIFICATE REQUEST----- diff --git a/test/units/module_utils/acme/fixtures/csr_1.txt b/test/units/module_utils/acme/fixtures/csr_1.txt new file mode 100644 index 0000000000..37c5cbda72 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/csr_1.txt @@ -0,0 +1,28 @@ +Certificate Request: + Data: + Version: 1 (0x0) + Subject: CN = ansible.com + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + pub: + 04:00:9c:f4:c8:00:17:03:01:26:3a:14:d1:92:35: + f1:c2:07:9d:6d:63:ba:82:86:d8:33:79:56:b3:3a: + d2:eb:c1:bc:41:2c:e1:5d:1e:80:99:0d:c8:cd:90: + e2:9a:74:d3:5c:ee:d7:85:5c:a5:0d:3f:12:2f:31: + 38:e3:f1:29:9b + ASN1 OID: prime256v1 + NIST CURVE: P-256 + Attributes: + Requested Extensions: + X509v3 Subject Alternative Name: + DNS:example.com, DNS:example.org + X509v3 Basic Constraints: critical + CA:FALSE + X509v3 Key Usage: critical + Digital Signature + Signature Algorithm: ecdsa-with-SHA256 + 30:44:02:20:70:3c:a8:46:6c:05:54:10:e5:16:f6:c5:66:d8: + 92:77:9c:26:25:4d:65:b4:ce:89:b5:c7:e7:2d:69:e3:63:9e: + 02:20:2a:ee:38:1c:ab:ae:8a:45:52:43:8a:29:be:31:5c:e6: + 2c:81:a0:d3:f7:75:ab:51:19:c2:25:f4:cb:af:64:f7 diff --git a/test/units/module_utils/acme/fixtures/csr_2.pem b/test/units/module_utils/acme/fixtures/csr_2.pem new file mode 100644 index 0000000000..295a26e1cf --- /dev/null +++ b/test/units/module_utils/acme/fixtures/csr_2.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIEqjCCApICAQAwADCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANv1 +V7gDsh76O//d9wclBcW6kNpWeR6eAggzThwbMZjcO7GFHQsBZCZGGVdyS37uhejc +RrIBdtDDWXhoh3Dz+GQxD+6GuwAEFyL1F3MfT0v1HHoO8fE74G5mD6+ZA2HRDeU9 +jf8BPyVWHBtNbCmJGSlSNOFejWCmwvsLARQxqFBuTyRjgos4BkLyWMqZRukrzO1P +z7IBhuFrB608t+AG4vGnPXZNM7xefhzO8bPOiepT0YS2ERPkFmOy97SnwTGdKykw +ZYM9oKukYhE4Z+yOaTFpJMBNXwDCI5TMnhtc6eJrf5sOFH92n2E9+YWMoahUOiTw +G6XV5HfSpySpwORUaTITQRsPAM+bmK9f1jB6ctfFVwpa8uW/h8pSgbHgZvkeD6s6 +rFLh9TQ24t0vrRmhnY7/AMFgbgJoBTBq0l0lEXS4FCGKDGqQOqSws+eHR/pHA4uY +v8d498SQl9fYsT/c7Uj3/JnMSRVN942yQUFCzwLf0/WzWCi2HTqPM8CPh5ryiJ30 +GAN2eb026/noyTOXm479Tg9o86Tw9qczE0j0CdcRnr6J337RGHQg58PZ7j+hnUmK +wgyclyvjE10ZFBgToMGSnzYp5UeRcOFZ3bnK6LOsGC75mIvz2OQgSQeO5VQASEnO +9uhygNyo91sK4BtVroloit8ZCa82LlsHSCj/mMzPAgMBAAGgZTBjBgkqhkiG9w0B +CQ4xVjBUMFIGA1UdEQRLMEmCC2Fuc2libGUuY29thwR/AAABhxAAAAAAAAAAAAAA +AAAAAAABhxAgAQ2IrBD+AQAAAAAAAAAAhxAgARI0VnirzZh2VDIQ/ty6MA0GCSqG +SIb3DQEBCwUAA4ICAQBFRuANzVRcze+iur0YevjtYIXDa03GoWWkgnLuE8u8epTM +2248duG3TmvVvxWPN4iFrvFcZIvNsevBo+Z7kXJ24m3YldtXvwfAYmCZ062apSoh +yzgo3Q0KfDehwLcoJPe5bh+jbbgJVGGvJug/QFyHSVl+iGyFUXE7pwafl9LuNDi3 +yfOYZLIQ34mBH4Rsvymj9xSTYliWDEEU/o7RrrZeEqkOxNeLh64LbnifdrYUputz +yBURg2xs9hpAsytZJX90iJW8aYPM1aQ7eetqTViIRoqUAmIQobnKlNnpOliBHl+p +RY+AtTnsfAetKUP7OsAZkHRTGAXx0JHJQ1ITY8w5Dcw/v1bDCbAfkDubBP3X+us9 +RQk2h6m74hWFFNu9xOfkNejPf7h4gywfDjo/wGZFSWKyi6avB9V53znZgRUwc009 +p5MM9e37MH8pyBqfnbSwOj4hUoyecRCIAFdywjMb9akP2u15XP3MOtJOEvecyCxN +TZBxupTg65zB47GeSAufnc8FaTZkE8xPuCtbvqOVOkWYqzlqNdCfK8f3AZdlpwLh +38wdUm5G7LIu6aQNiY66aQs9qVpoGvqdmxHRkuSwqwZxGgzcY1yJaWGXQ6R4jgC3 +VKlMTUVs1WYV6jrYLHcVt6Rn/2FVTOns3Jn6cTPOdKViYoqF+yW8yCEAqAskZw== +-----END CERTIFICATE REQUEST----- diff --git a/test/units/module_utils/acme/fixtures/csr_2.txt b/test/units/module_utils/acme/fixtures/csr_2.txt new file mode 100644 index 0000000000..7a54ee3fa2 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/csr_2.txt @@ -0,0 +1,78 @@ +Certificate Request: + Data: + Version: 1 (0x0) + Subject: + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + RSA Public-Key: (4096 bit) + Modulus: + 00:db:f5:57:b8:03:b2:1e:fa:3b:ff:dd:f7:07:25: + 05:c5:ba:90:da:56:79:1e:9e:02:08:33:4e:1c:1b: + 31:98:dc:3b:b1:85:1d:0b:01:64:26:46:19:57:72: + 4b:7e:ee:85:e8:dc:46:b2:01:76:d0:c3:59:78:68: + 87:70:f3:f8:64:31:0f:ee:86:bb:00:04:17:22:f5: + 17:73:1f:4f:4b:f5:1c:7a:0e:f1:f1:3b:e0:6e:66: + 0f:af:99:03:61:d1:0d:e5:3d:8d:ff:01:3f:25:56: + 1c:1b:4d:6c:29:89:19:29:52:34:e1:5e:8d:60:a6: + c2:fb:0b:01:14:31:a8:50:6e:4f:24:63:82:8b:38: + 06:42:f2:58:ca:99:46:e9:2b:cc:ed:4f:cf:b2:01: + 86:e1:6b:07:ad:3c:b7:e0:06:e2:f1:a7:3d:76:4d: + 33:bc:5e:7e:1c:ce:f1:b3:ce:89:ea:53:d1:84:b6: + 11:13:e4:16:63:b2:f7:b4:a7:c1:31:9d:2b:29:30: + 65:83:3d:a0:ab:a4:62:11:38:67:ec:8e:69:31:69: + 24:c0:4d:5f:00:c2:23:94:cc:9e:1b:5c:e9:e2:6b: + 7f:9b:0e:14:7f:76:9f:61:3d:f9:85:8c:a1:a8:54: + 3a:24:f0:1b:a5:d5:e4:77:d2:a7:24:a9:c0:e4:54: + 69:32:13:41:1b:0f:00:cf:9b:98:af:5f:d6:30:7a: + 72:d7:c5:57:0a:5a:f2:e5:bf:87:ca:52:81:b1:e0: + 66:f9:1e:0f:ab:3a:ac:52:e1:f5:34:36:e2:dd:2f: + ad:19:a1:9d:8e:ff:00:c1:60:6e:02:68:05:30:6a: + d2:5d:25:11:74:b8:14:21:8a:0c:6a:90:3a:a4:b0: + b3:e7:87:47:fa:47:03:8b:98:bf:c7:78:f7:c4:90: + 97:d7:d8:b1:3f:dc:ed:48:f7:fc:99:cc:49:15:4d: + f7:8d:b2:41:41:42:cf:02:df:d3:f5:b3:58:28:b6: + 1d:3a:8f:33:c0:8f:87:9a:f2:88:9d:f4:18:03:76: + 79:bd:36:eb:f9:e8:c9:33:97:9b:8e:fd:4e:0f:68: + f3:a4:f0:f6:a7:33:13:48:f4:09:d7:11:9e:be:89: + df:7e:d1:18:74:20:e7:c3:d9:ee:3f:a1:9d:49:8a: + c2:0c:9c:97:2b:e3:13:5d:19:14:18:13:a0:c1:92: + 9f:36:29:e5:47:91:70:e1:59:dd:b9:ca:e8:b3:ac: + 18:2e:f9:98:8b:f3:d8:e4:20:49:07:8e:e5:54:00: + 48:49:ce:f6:e8:72:80:dc:a8:f7:5b:0a:e0:1b:55: + ae:89:68:8a:df:19:09:af:36:2e:5b:07:48:28:ff: + 98:cc:cf + Exponent: 65537 (0x10001) + Attributes: + Requested Extensions: + X509v3 Subject Alternative Name: + DNS:ansible.com, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:2001:D88:AC10:FE01:0:0:0:0, IP Address:2001:1234:5678:ABCD:9876:5432:10FE:DCBA + Signature Algorithm: sha256WithRSAEncryption + 45:46:e0:0d:cd:54:5c:cd:ef:a2:ba:bd:18:7a:f8:ed:60:85: + c3:6b:4d:c6:a1:65:a4:82:72:ee:13:cb:bc:7a:94:cc:db:6e: + 3c:76:e1:b7:4e:6b:d5:bf:15:8f:37:88:85:ae:f1:5c:64:8b: + cd:b1:eb:c1:a3:e6:7b:91:72:76:e2:6d:d8:95:db:57:bf:07: + c0:62:60:99:d3:ad:9a:a5:2a:21:cb:38:28:dd:0d:0a:7c:37: + a1:c0:b7:28:24:f7:b9:6e:1f:a3:6d:b8:09:54:61:af:26:e8: + 3f:40:5c:87:49:59:7e:88:6c:85:51:71:3b:a7:06:9f:97:d2: + ee:34:38:b7:c9:f3:98:64:b2:10:df:89:81:1f:84:6c:bf:29: + a3:f7:14:93:62:58:96:0c:41:14:fe:8e:d1:ae:b6:5e:12:a9: + 0e:c4:d7:8b:87:ae:0b:6e:78:9f:76:b6:14:a6:eb:73:c8:15: + 11:83:6c:6c:f6:1a:40:b3:2b:59:25:7f:74:88:95:bc:69:83: + cc:d5:a4:3b:79:eb:6a:4d:58:88:46:8a:94:02:62:10:a1:b9: + ca:94:d9:e9:3a:58:81:1e:5f:a9:45:8f:80:b5:39:ec:7c:07: + ad:29:43:fb:3a:c0:19:90:74:53:18:05:f1:d0:91:c9:43:52: + 13:63:cc:39:0d:cc:3f:bf:56:c3:09:b0:1f:90:3b:9b:04:fd: + d7:fa:eb:3d:45:09:36:87:a9:bb:e2:15:85:14:db:bd:c4:e7: + e4:35:e8:cf:7f:b8:78:83:2c:1f:0e:3a:3f:c0:66:45:49:62: + b2:8b:a6:af:07:d5:79:df:39:d9:81:15:30:73:4d:3d:a7:93: + 0c:f5:ed:fb:30:7f:29:c8:1a:9f:9d:b4:b0:3a:3e:21:52:8c: + 9e:71:10:88:00:57:72:c2:33:1b:f5:a9:0f:da:ed:79:5c:fd: + cc:3a:d2:4e:12:f7:9c:c8:2c:4d:4d:90:71:ba:94:e0:eb:9c: + c1:e3:b1:9e:48:0b:9f:9d:cf:05:69:36:64:13:cc:4f:b8:2b: + 5b:be:a3:95:3a:45:98:ab:39:6a:35:d0:9f:2b:c7:f7:01:97: + 65:a7:02:e1:df:cc:1d:52:6e:46:ec:b2:2e:e9:a4:0d:89:8e: + ba:69:0b:3d:a9:5a:68:1a:fa:9d:9b:11:d1:92:e4:b0:ab:06: + 71:1a:0c:dc:63:5c:89:69:61:97:43:a4:78:8e:00:b7:54:a9: + 4c:4d:45:6c:d5:66:15:ea:3a:d8:2c:77:15:b7:a4:67:ff:61: + 55:4c:e9:ec:dc:99:fa:71:33:ce:74:a5:62:62:8a:85:fb:25: + bc:c8:21:00:a8:0b:24:67 diff --git a/test/units/module_utils/acme/fixtures/privatekey_1.pem b/test/units/module_utils/acme/fixtures/privatekey_1.pem new file mode 100644 index 0000000000..97209eda71 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/privatekey_1.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAoGCCqGSM49 +AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6A +mQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw== +-----END EC PRIVATE KEY----- diff --git a/test/units/module_utils/acme/fixtures/privatekey_1.txt b/test/units/module_utils/acme/fixtures/privatekey_1.txt new file mode 100644 index 0000000000..e25cfd4516 --- /dev/null +++ b/test/units/module_utils/acme/fixtures/privatekey_1.txt @@ -0,0 +1,14 @@ +read EC key +Private-Key: (256 bit) +priv: + 35:9a:8d:4d:0f:ca:16:0a:7a:e9:5f:cb:f9:6e:36: + d9:00:bd:ee:c3:93:04:34:d5:b5:c9:f7:bc:db:c4: + 1e:ba +pub: + 04:00:9c:f4:c8:00:17:03:01:26:3a:14:d1:92:35: + f1:c2:07:9d:6d:63:ba:82:86:d8:33:79:56:b3:3a: + d2:eb:c1:bc:41:2c:e1:5d:1e:80:99:0d:c8:cd:90: + e2:9a:74:d3:5c:ee:d7:85:5c:a5:0d:3f:12:2f:31: + 38:e3:f1:29:9b +ASN1 OID: prime256v1 +NIST CURVE: P-256 diff --git a/test/units/module_utils/test_acme.py b/test/units/module_utils/acme/test_acme.py similarity index 61% rename from test/units/module_utils/test_acme.py rename to test/units/module_utils/acme/test_acme.py index 2847c9bf7b..f7589c596b 100644 --- a/test/units/module_utils/test_acme.py +++ b/test/units/module_utils/acme/test_acme.py @@ -1,5 +1,6 @@ import base64 import datetime +import os.path import pytest from mock import MagicMock @@ -15,10 +16,18 @@ from ansible.module_utils.acme import ( # _sign_request_openssl, _parse_key_cryptography, # _sign_request_cryptography, - cryptography_get_csr_domains, + _normalize_ip, + openssl_get_csr_identifiers, + cryptography_get_csr_identifiers, cryptography_get_cert_days, ) + +def load_fixture(name): + with open(os.path.join(os.path.dirname(__file__), 'fixtures', name)) as f: + return f.read() + + ################################################ NOPAD_B64 = [ @@ -58,13 +67,7 @@ def test_write_file(tmpdir): TEST_PEM_DERS = [ ( - r""" ------BEGIN EC PRIVATE KEY----- -MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAoGCCqGSM49 -AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6A -mQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw== ------END EC PRIVATE KEY----- -""", + load_fixture('privatekey_1.pem'), base64.b64decode('MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAo' 'GCCqGSM49AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3' 'lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==') @@ -83,13 +86,7 @@ def test_pem_to_der(pem, der, tmpdir): TEST_KEYS = [ ( - r""" ------BEGIN EC PRIVATE KEY----- -MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAoGCCqGSM49 -AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6A -mQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw== ------END EC PRIVATE KEY----- -""", + load_fixture('privatekey_1.pem'), { 'alg': 'ES256', 'hash': 'sha256', @@ -102,22 +99,7 @@ mQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw== 'point_size': 32, 'type': 'ec', }, - r""" -read EC key -Private-Key: (256 bit) -priv: - 35:9a:8d:4d:0f:ca:16:0a:7a:e9:5f:cb:f9:6e:36: - d9:00:bd:ee:c3:93:04:34:d5:b5:c9:f7:bc:db:c4: - 1e:ba -pub: - 04:00:9c:f4:c8:00:17:03:01:26:3a:14:d1:92:35: - f1:c2:07:9d:6d:63:ba:82:86:d8:33:79:56:b3:3a: - d2:eb:c1:bc:41:2c:e1:5d:1e:80:99:0d:c8:cd:90: - e2:9a:74:d3:5c:ee:d7:85:5c:a5:0d:3f:12:2f:31: - 38:e3:f1:29:9b -ASN1 OID: prime256v1 -NIST CURVE: P-256 -""" + load_fixture('privatekey_1.txt'), ) ] @@ -146,43 +128,73 @@ if HAS_CURRENT_CRYPTOGRAPHY: ################################################ -TEST_CSR = r""" ------BEGIN NEW CERTIFICATE REQUEST----- -MIIBJTCBzQIBADAWMRQwEgYDVQQDEwthbnNpYmxlLmNvbTBZMBMGByqGSM49AgEG -CCqGSM49AwEHA0IABACc9MgAFwMBJjoU0ZI18cIHnW1juoKG2DN5VrM60uvBvEEs -4V0egJkNyM2Q4pp001zu14VcpQ0/Ei8xOOPxKZugVTBTBgkqhkiG9w0BCQ4xRjBE -MCMGA1UdEQQcMBqCC2V4YW1wbGUuY29tggtleGFtcGxlLm9yZzAMBgNVHRMBAf8E -AjAAMA8GA1UdDwEB/wQFAwMHgAAwCgYIKoZIzj0EAwIDRwAwRAIgcDyoRmwFVBDl -FvbFZtiSd5wmJU1ltM6JtcfnLWnjY54CICruOByrropFUkOKKb4xXOYsgaDT93Wr -URnCJfTLr2T3 ------END NEW CERTIFICATE REQUEST----- -""" +TEST_IPS = [ + ("0:0:0:0:0:0:0:1", "::1"), + ("1::0:2", "1::2"), + ("0000:0001:0000:0000:0000:0000:0000:0001", "0:1::1"), + ("0000:0001:0000:0000:0001:0000:0000:0001", "0:1::1:0:0:1"), + ("0000:0001:0000:0001:0000:0001:0000:0001", "0:1:0:1:0:1:0:1"), + ("0.0.0.0", "0.0.0.0"), + ("000.001.000.000", "0.1.0.0"), + ("2001:d88:ac10:fe01:0:0:0:0", "2001:d88:ac10:fe01::"), + ("0000:0000:0000:0000:0000:0000:0000:0000", "::"), +] -if HAS_CURRENT_CRYPTOGRAPHY: - def test_csrdomains_cryptography(tmpdir): - fn = tmpdir / 'test.csr' - fn.write(TEST_CSR) - module = MagicMock() - domains = cryptography_get_csr_domains(module, str(fn)) - assert domains == set(['ansible.com', 'example.com', 'example.org']) +@pytest.mark.parametrize("ip, result", TEST_IPS) +def test_normalize_ip(ip, result): + assert _normalize_ip(ip) == result ################################################ -TEST_CERT = r""" ------BEGIN CERTIFICATE----- -MIIBljCCATugAwIBAgIBATAKBggqhkjOPQQDAjAWMRQwEgYDVQQDEwthbnNpYmxl -LmNvbTAeFw0xODExMjUxNTI4MjNaFw0xODExMjYxNTI4MjRaMBYxFDASBgNVBAMT -C2Fuc2libGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAJz0yAAXAwEm -OhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6AmQ3IzZDimnTTXO7XhVylDT8S -LzE44/Epm6N6MHgwIwYDVR0RBBwwGoILZXhhbXBsZS5jb22CC2V4YW1wbGUub3Jn -MAwGA1UdEwEB/wQCMAAwDwYDVR0PAQH/BAUDAweAADATBgNVHSUEDDAKBggrBgEF -BQcDATAdBgNVHQ4EFgQUmNL9PMzNaUX74owwLFRiGDS3B3MwCgYIKoZIzj0EAwID -SQAwRgIhALz7Ur96ky0OfM5D9MwFmCg2jccqm/UglGI9+4KeOEIyAiEAwFX4tdll -QSrd1HY/jMsHwdK5wH3JkK/9+fGwyRP11VI= ------END CERTIFICATE----- -""" +TEST_CSRS = [ + ( + load_fixture('csr_1.pem'), + set([ + ('dns', 'ansible.com'), + ('dns', 'example.com'), + ('dns', 'example.org') + ]), + load_fixture('csr_1.txt'), + ), + ( + load_fixture('csr_2.pem'), + set([ + ('dns', 'ansible.com'), + ('ip', '127.0.0.1'), + ('ip', '::1'), + ('ip', '2001:d88:ac10:fe01::'), + ('ip', '2001:1234:5678:abcd:9876:5432:10fe:dcba') + ]), + load_fixture('csr_2.txt'), + ), +] + + +@pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) +def test_csridentifiers_openssl(csr, result, openssl_output, tmpdir): + fn = tmpdir / 'test.csr' + fn.write(csr) + module = MagicMock() + module.run_command = MagicMock(return_value=(0, openssl_output, 0)) + identifiers = openssl_get_csr_identifiers('openssl', module, str(fn)) + assert identifiers == result + + +if HAS_CURRENT_CRYPTOGRAPHY: + @pytest.mark.parametrize("csr, result, openssl_output", TEST_CSRS) + def test_csridentifiers_cryptography(csr, result, openssl_output, tmpdir): + fn = tmpdir / 'test.csr' + fn.write(csr) + module = MagicMock() + identifiers = cryptography_get_csr_identifiers(module, str(fn)) + assert identifiers == result + + +################################################ + +TEST_CERT = load_fixture("cert_1.pem") TEST_CERT_DAYS = [ (datetime.datetime(2018, 11, 15, 1, 2, 3), 11),