Skip to content

Commit d50c3cc

Browse files
authored
get_certificate: add get_certificate_chain option (#784)
* Implement get_certificate_chain option. * Implement basic tests. * Add compatibility for current Python 3.13 pre-releases.
1 parent 4c26fad commit d50c3cc

File tree

3 files changed

+114
-3
lines changed

3 files changed

+114
-3
lines changed

plugins/modules/get_certificate.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,23 @@
107107
type: list
108108
elements: raw
109109
version_added: 2.21.0
110+
get_certificate_chain:
111+
description:
112+
- If set to V(true), will obtain the certificate chain next to the certificate itself.
113+
- The chain as returned by the server can be found in RV(unverified_chain), and the chain that passed validation
114+
in RV(verified_chain).
115+
- B(Note) that this needs B(Python 3.10 or newer). Also note that only Python 3.13 or newer officially supports this.
116+
The module uses internal APIs of Python 3.10, 3.11, and 3.12 to achieve the same. It can be that future versions of
117+
Python 3.10, 3.11, or 3.12 break this.
118+
type: bool
119+
default: false
120+
version_added: 2.21.0
110121
111122
notes:
112123
- When using ca_cert on OS X it has been reported that in some conditions the validate will always succeed.
113124
114125
requirements:
115-
- "Python >= 2.7 when using O(proxy_host)"
126+
- "Python >= 2.7 when using O(proxy_host), and Python >= 3.10 when O(get_certificate_chain=true)"
116127
- "cryptography >= 1.6"
117128
118129
seealso:
@@ -189,6 +200,26 @@
189200
description: The version number of the certificate.
190201
returned: success
191202
type: str
203+
verified_chain:
204+
description:
205+
- The verified certificate chain retrieved from the port.
206+
- The first entry is always RV(cert).
207+
- The last certificate the root certificate the chain is traced to. If O(ca_cert) is provided this certificate is part of that store;
208+
otherwise it is part of the store used by default by Python.
209+
- Note that RV(unverified_chain) generally does not contain the root certificate, and might contain other certificates that are not part
210+
of the validated chain.
211+
returned: success and O(get_certificate_chain=true)
212+
type: list
213+
elements: str
214+
version_added: 2.21.0
215+
unverified_chain:
216+
description:
217+
- The certificate chain retrieved from the port.
218+
- The first entry is always RV(cert).
219+
returned: success and O(get_certificate_chain=true)
220+
type: list
221+
elements: str
222+
version_added: 2.21.0
192223
'''
193224

194225
EXAMPLES = '''
@@ -240,6 +271,7 @@
240271
import base64
241272
import traceback
242273
import ssl
274+
import sys
243275

244276
from os.path import isfile
245277
from socket import create_connection, setdefaulttimeout, socket
@@ -317,6 +349,7 @@ def main():
317349
ciphers=dict(type='list', elements='str'),
318350
asn1_base64=dict(type='bool'),
319351
tls_ctx_options=dict(type='list', elements='raw'),
352+
get_certificate_chain=dict(type='bool', default=False),
320353
),
321354
)
322355

@@ -330,7 +363,9 @@ def main():
330363
start_tls_server_type = module.params.get('starttls')
331364
ciphers = module.params.get('ciphers')
332365
asn1_base64 = module.params['asn1_base64']
333-
tls_ctx_options = module.params.get('tls_ctx_options')
366+
tls_ctx_options = module.params['tls_ctx_options']
367+
get_certificate_chain = module.params['get_certificate_chain']
368+
334369
if asn1_base64 is None:
335370
module.deprecate(
336371
'The default value `false` for asn1_base64 is deprecated and will change to `true` in '
@@ -341,6 +376,12 @@ def main():
341376
)
342377
asn1_base64 = False
343378

379+
if get_certificate_chain and sys.version_info < (3, 10):
380+
module.fail_json(
381+
msg='get_certificate_chain=true can only be used with Python 3.10 (Python 3.13+ officially supports this). '
382+
'The Python version used to run the get_certificate module is %s' % sys.version
383+
)
384+
344385
backend = module.params.get('select_crypto_backend')
345386
if backend == 'auto':
346387
# Detection what is possible
@@ -371,6 +412,9 @@ def main():
371412
if not isfile(ca_cert):
372413
module.fail_json(msg="ca_cert file does not exist")
373414

415+
verified_chain = None
416+
unverified_chain = None
417+
374418
if not HAS_CREATE_DEFAULT_CONTEXT:
375419
# Python < 2.7.9
376420
if proxy_host:
@@ -450,8 +494,43 @@ def main():
450494
except Exception as e:
451495
module.fail_json(msg="Failed to add {0} to CTX options".format(tls_ctx_option_str or tls_ctx_option_int))
452496

453-
cert = ctx.wrap_socket(sock, server_hostname=server_name or host).getpeercert(True)
497+
tls_sock = ctx.wrap_socket(sock, server_hostname=server_name or host)
498+
cert = tls_sock.getpeercert(True)
454499
cert = DER_cert_to_PEM_cert(cert)
500+
501+
if get_certificate_chain:
502+
if sys.version_info < (3, 13):
503+
# The official way to access this has been added in https://github.com/python/cpython/pull/109113/files.
504+
# We're basically doing the same for older Python versions. The internal API needed for this was added
505+
# in https://github.com/python/cpython/commit/666991fc598bc312d72aff0078ecb553f0a968f1, which was first
506+
# released in Python 3.10.0.
507+
def _convert_chain(chain):
508+
if not chain:
509+
return []
510+
return [c.public_bytes(ssl._ssl.ENCODING_DER) for c in chain]
511+
512+
ssl_obj = tls_sock._sslobj # This is of type ssl._ssl._SSLSocket
513+
verified_der_chain = _convert_chain(ssl_obj.get_verified_chain())
514+
unverified_der_chain = _convert_chain(ssl_obj.get_unverified_chain())
515+
else:
516+
# This works with Python 3.13+
517+
518+
# Unfortunately due to a bug (https://github.com/python/cpython/issues/118658) some early pre-releases of
519+
# Python 3.13 do not return lists of byte strings, but lists of _ssl.Certificate objects. This is going to
520+
# be fixed by https://github.com/python/cpython/pull/118669. For now we convert the certificates ourselves
521+
# if they are not byte strings to work around this.
522+
def _convert_chain(chain):
523+
return [
524+
c if isinstance(c, bytes) else c.public_bytes(ssl._ssl.ENCODING_DER)
525+
for c in chain
526+
]
527+
528+
verified_der_chain = _convert_chain(tls_sock.get_verified_chain())
529+
unverified_der_chain = _convert_chain(tls_sock.get_unverified_chain())
530+
531+
verified_chain = [DER_cert_to_PEM_cert(c) for c in verified_der_chain]
532+
unverified_chain = [DER_cert_to_PEM_cert(c) for c in unverified_der_chain]
533+
455534
except Exception as e:
456535
if proxy_host:
457536
module.fail_json(msg="Failed to get cert via proxy {0}:{1} from {2}:{3}, error: {4}".format(
@@ -499,6 +578,11 @@ def main():
499578
else:
500579
result['version'] = "unknown"
501580

581+
if verified_chain is not None:
582+
result['verified_chain'] = verified_chain
583+
if unverified_chain is not None:
584+
result['unverified_chain'] = unverified_chain
585+
502586
module.exit_json(**result)
503587

504588

tests/integration/targets/get_certificate/tasks/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
- set_fact:
1212
skip_tests: false
13+
has_get_certificate_chain: >-
14+
{{ ansible_facts.python_version is version('3.10.0', '>=') }}
1315
1416
- block:
1517

tests/integration/targets/get_certificate/tests/validate.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,38 @@
123123
port: 443
124124
select_crypto_backend: "{{ select_crypto_backend }}"
125125
asn1_base64: true
126+
get_certificate_chain: "{{ has_get_certificate_chain }}"
126127
register: result
127128

128129
- assert:
129130
that:
130131
- result is not changed
131132
- result is not failed
132133

134+
- name: Read CA cert
135+
slurp:
136+
src: '{{ remote_tmp_dir }}/temp.pem'
137+
register: cacert
138+
when: has_get_certificate_chain
139+
140+
- name: Validate get_certificate_chain=true results
141+
assert:
142+
that:
143+
- result.verified_chain is sequence
144+
- result.unverified_chain is sequence
145+
- result.verified_chain[0] == result.cert
146+
- result.unverified_chain[0] == result.cert
147+
- result.verified_chain[-1] == cacert.content | b64decode
148+
- result.verified_chain == result.unverified_chain + [cacert.content | b64decode]
149+
when: has_get_certificate_chain
150+
151+
- name: Validate get_certificate_chain=false results
152+
assert:
153+
that:
154+
- result.verified_chain is undefined
155+
- result.unverified_chain is undefined
156+
when: not has_get_certificate_chain
157+
133158
- name: Generate bogus CA privatekey
134159
openssl_privatekey:
135160
path: '{{ remote_tmp_dir }}/bogus_ca.key'

0 commit comments

Comments
 (0)