ACME: add support for IP identifiers (#53660)

* Adding support for IP identifiers according to https://tools.ietf.org/html/draft-ietf-acme-ip-05.

* Add changelog.

* Make sure that the authorizations return value is unchanged for CSRs with DNS-only SANs.

* Remove unneeded import.

* type -> identifier_type

* Python 2.6 compatibility.

* Fix unit tests.

* Add IP address normalization.

* Extend tests.

* Move data into fixtures.

* Adjust BOTMETA.
This commit is contained in:
Felix Fontein 2019-03-13 10:16:56 +01:00 committed by René Moser
parent 028facdfed
commit c2cb82ec14
12 changed files with 449 additions and 148 deletions

2
.github/BOTMETA.yml vendored
View file

@ -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

View file

@ -0,0 +1,2 @@
minor_changes:
- "acme_certificate - add experimental support for IP address identifiers."

View file

@ -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):

View file

@ -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,

View file

@ -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-----

View file

@ -0,0 +1,9 @@
-----BEGIN NEW CERTIFICATE REQUEST-----
MIIBJTCBzQIBADAWMRQwEgYDVQQDEwthbnNpYmxlLmNvbTBZMBMGByqGSM49AgEG
CCqGSM49AwEHA0IABACc9MgAFwMBJjoU0ZI18cIHnW1juoKG2DN5VrM60uvBvEEs
4V0egJkNyM2Q4pp001zu14VcpQ0/Ei8xOOPxKZugVTBTBgkqhkiG9w0BCQ4xRjBE
MCMGA1UdEQQcMBqCC2V4YW1wbGUuY29tggtleGFtcGxlLm9yZzAMBgNVHRMBAf8E
AjAAMA8GA1UdDwEB/wQFAwMHgAAwCgYIKoZIzj0EAwIDRwAwRAIgcDyoRmwFVBDl
FvbFZtiSd5wmJU1ltM6JtcfnLWnjY54CICruOByrropFUkOKKb4xXOYsgaDT93Wr
URnCJfTLr2T3
-----END NEW CERTIFICATE REQUEST-----

View file

@ -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

View file

@ -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-----

View file

@ -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

View file

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDWajU0PyhYKeulfy/luNtkAve7DkwQ01bXJ97zbxB66oAoGCCqGSM49
AwEHoUQDQgAEAJz0yAAXAwEmOhTRkjXxwgedbWO6gobYM3lWszrS68G8QSzhXR6A
mQ3IzZDimnTTXO7XhVylDT8SLzE44/Epmw==
-----END EC PRIVATE KEY-----

View file

@ -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

View file

@ -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),