openssl_*: improve passphrase handling for private keys in PyOpenSSL (#53489)

* Raise OpenSSLBadPassphraseError if passphrase is wrong.

* Improve handling of passphrase errors.

Current behavior for modules is: if passphrase is wrong (or wrongly specified), fail.
Current behavior for openssl_privatekey is: if passphrase is worng (or wrongly specified), regenerate.

* Add changelog.

* Add tests.

* Adjustments for some versions of PyOpenSSL.

* Update lib/ansible/modules/crypto/openssl_certificate.py

Improve text.

Co-Authored-By: felixfontein <felix@fontein.de>
pull/53537/head
Felix Fontein 6 years ago committed by John R Barker
parent 1d91e03119
commit caf7fd2245

@ -0,0 +1,6 @@
bugfixes:
- "openssl_privatekey - no longer hang or crash when passphrase does not match or was
not specified, but key is protected with one. Also regenerate key if passphrase is
specified but existing key has no passphrase."
- "openssl_csr, openssl_certificate, openssl_publickey - properly validate private key
passphrase; if it doesn't match, fail (and not crash or ignore)."

@ -38,6 +38,10 @@ class OpenSSLObjectError(Exception):
pass
class OpenSSLBadPassphraseError(OpenSSLObjectError):
pass
def get_fingerprint_of_bytes(source):
"""Generate the fingerprint of the given bytes."""
@ -67,7 +71,7 @@ def get_fingerprint_of_bytes(source):
def get_fingerprint(path, passphrase=None):
"""Generate the fingerprint of the public key. """
privatekey = load_privatekey(path, passphrase)
privatekey = load_privatekey(path, passphrase, check_passphrase=False)
try:
publickey = crypto.dump_publickey(crypto.FILETYPE_ASN1, privatekey)
return get_fingerprint_of_bytes(publickey)
@ -78,20 +82,46 @@ def get_fingerprint(path, passphrase=None):
return None
def load_privatekey(path, passphrase=None):
def load_privatekey(path, passphrase=None, check_passphrase=True):
"""Load the specified OpenSSL private key."""
try:
with open(path, 'rb') as b_priv_key_fh:
priv_key_detail = b_priv_key_fh.read()
if passphrase:
return crypto.load_privatekey(crypto.FILETYPE_PEM,
# First try: try to load with real passphrase (resp. empty string)
# Will work if this is the correct passphrase, or the key is not
# password-protected.
try:
result = crypto.load_privatekey(crypto.FILETYPE_PEM,
priv_key_detail,
to_bytes(passphrase))
to_bytes(passphrase or ''))
except crypto.Error as e:
if len(e.args) > 0 and len(e.args[0]) > 0 and e.args[0][0][2] == 'bad decrypt':
# This happens in case we have the wrong passphrase.
if passphrase is not None:
raise OpenSSLBadPassphraseError('Wrong passphrase provided for private key!')
else:
return crypto.load_privatekey(crypto.FILETYPE_PEM,
priv_key_detail)
raise OpenSSLBadPassphraseError('No passphrase provided, but private key is password-protected!')
raise
if check_passphrase:
# Next we want to make sure that the key is actually protected by
# a passphrase (in case we did try the empty string before, make
# sure that the key is not protected by the empty string)
try:
crypto.load_privatekey(crypto.FILETYPE_PEM,
priv_key_detail,
to_bytes('y' if passphrase == 'x' else 'x'))
if passphrase is not None:
# Since we can load the key without an exception, the
# key isn't password-protected
raise OpenSSLBadPassphraseError('Passphrase provided, but private key is not password-protected!')
except crypto.Error:
if passphrase is None and len(e.args) > 0 and len(e.args[0]) > 0 and e.args[0][0][2] == 'bad decrypt':
# The key is obviously protected by the empty string.
# Don't do this at home (if it's possible at all)...
raise OpenSSLBadPassphraseError('No passphrase provided, but private key is password-protected!')
return result
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc)

@ -596,10 +596,13 @@ class Certificate(crypto_utils.OpenSSLObject):
self.cert = crypto_utils.load_certificate(self.path)
if self.privatekey_path:
try:
self.privatekey = crypto_utils.load_privatekey(
self.privatekey_path,
self.privatekey_passphrase
)
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise CertificateError(exc)
return _validate_privatekey()
if self.csr_path:
@ -621,9 +624,12 @@ class SelfSignedCertificate(Certificate):
self.version = module.params['selfsigned_version']
self.serial_number = randint(1000, 99999)
self.csr = crypto_utils.load_certificate_request(self.csr_path)
try:
self.privatekey = crypto_utils.load_privatekey(
self.privatekey_path, self.privatekey_passphrase
)
except crypto_utils.OpenSSLBadPassphraseError as exc:
module.fail_json(msg=str(exc))
def generate(self, module):
@ -703,9 +709,12 @@ class OwnCACertificate(Certificate):
self.ca_privatekey_passphrase = module.params['ownca_privatekey_passphrase']
self.csr = crypto_utils.load_certificate_request(self.csr_path)
self.ca_cert = crypto_utils.load_certificate(self.ca_cert_path)
try:
self.ca_privatekey = crypto_utils.load_privatekey(
self.ca_privatekey_path, self.ca_privatekey_passphrase
)
except crypto_utils.OpenSSLBadPassphraseError as exc:
module.fail_json(msg=str(exc))
def generate(self, module):
@ -1003,10 +1012,15 @@ class AssertOnlyCertificate(Certificate):
self.assertonly()
try:
if self.privatekey_path and \
not super(AssertOnlyCertificate, self).check(module, perms_required=False):
self.message.append(
'Certificate %s and private key %s does not match' % (self.path, self.privatekey_path)
'Certificate %s and private key %s do not match' % (self.path, self.privatekey_path)
)
except CertificateError as e:
self.message.append(
'Error while reading private key %s: %s' % (self.privatekey_path, str(e))
)
if len(self.message):

@ -528,7 +528,10 @@ class CertificateSigningRequestPyOpenSSL(CertificateSigningRequestBase):
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self.request)
def _load_private_key(self):
try:
self.privatekey = crypto_utils.load_privatekey(self.privatekey_path, self.privatekey_passphrase)
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise CertificateSigningRequestError(exc)
def _check_csr(self):
def _check_subject(csr):
@ -776,7 +779,7 @@ class CertificateSigningRequestCryptography(CertificateSigningRequestBase):
cryptography.x509.NameAttribute(self._get_name_oid(entry[0]), to_text(entry[1])) for entry in self.subject
]))
except ValueError as e:
raise CertificateSigningRequestError(str(e))
raise CertificateSigningRequestError(e)
if self.subjectAltName:
csr = csr.add_extension(cryptography.x509.SubjectAlternativeName([

@ -215,6 +215,8 @@ class Pkcs(crypto_utils.OpenSSLObject):
self.privatekey_passphrase)
except crypto.Error:
return False
except crypto_utils.OpenSSLBadPassphraseError:
return False
return True
if not state_and_perms:
@ -256,10 +258,13 @@ class Pkcs(crypto_utils.OpenSSLObject):
self.pkcs12.set_friendlyname(to_bytes(self.friendly_name))
if self.privatekey_path:
try:
self.pkcs12.set_privatekey(crypto_utils.load_privatekey(
self.privatekey_path,
self.privatekey_passphrase)
)
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise PkcsError(exc)
try:
pkcs12_file = os.open(self.path,

@ -365,6 +365,8 @@ class PrivateKeyPyOpenSSL(PrivateKeyBase):
return True
except crypto.Error:
return False
except crypto_utils.OpenSSLBadPassphraseError as exc:
return False
def _check_size_and_type(self):
def _check_size(privatekey):
@ -373,7 +375,10 @@ class PrivateKeyPyOpenSSL(PrivateKeyBase):
def _check_type(privatekey):
return self.type == privatekey.type()
try:
privatekey = crypto_utils.load_privatekey(self.path, self.passphrase)
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise PrivateKeyError(exc)
return _check_size(privatekey) and _check_type(privatekey)
@ -513,10 +518,19 @@ class PrivateKeyCryptography(PrivateKeyBase):
def _check_passphrase(self):
try:
self._load_privatekey()
with open(self.path, 'rb') as f:
return cryptography.hazmat.primitives.serialization.load_pem_private_key(
f.read(),
None if self.passphrase is None else to_bytes(self.passphrase),
backend=self.cryptography_backend
)
return True
except crypto.Error:
except TypeError as e:
if 'Password' in str(e) and 'encrypted' in str(e):
return False
raise PrivateKeyError(e)
except Exception as e:
raise PrivateKeyError(e)
def _check_size_and_type(self):
privatekey = self._load_privatekey()

@ -200,6 +200,8 @@ class PublicKey(crypto_utils.OpenSSLObject):
publickey_file.write(publickey_content)
self.changed = True
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise PublicKeyError(exc)
except (IOError, OSError) as exc:
raise PublicKeyError(exc)
except AttributeError as exc:
@ -237,10 +239,13 @@ class PublicKey(crypto_utils.OpenSSLObject):
except (crypto.Error, ValueError):
return False
try:
desired_publickey = crypto.dump_publickey(
crypto.FILETYPE_ASN1,
crypto_utils.load_privatekey(self.privatekey_path, self.privatekey_passphrase)
)
except crypto_utils.OpenSSLBadPassphraseError as exc:
raise PublicKeyError(exc)
return current_publickey == desired_publickey

@ -3,6 +3,13 @@
openssl_privatekey:
path: '{{ output_dir }}/privatekey.pem'
- name: Generate privatekey with password
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: auto
select_crypto_backend: cryptography
- name: Generate CSR (no extensions)
openssl_csr:
path: '{{ output_dir }}/csr_noext.csr'
@ -54,3 +61,39 @@
- "'Found no keyUsage extension' in extension_missing_ku.msg"
- extension_missing_eku is failed
- "'Found no extendedKeyUsage extension' in extension_missing_eku.msg"
- name: Check private key passphrase fail 1
openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem'
privatekey_path: '{{ output_dir }}/privatekey.pem'
privatekey_passphrase: hunter2
provider: assertonly
ignore_errors: yes
register: passphrase_error_1
- name: Check private key passphrase fail 2
openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
privatekey_passphrase: wrong_password
provider: assertonly
ignore_errors: yes
register: passphrase_error_2
- name: Check private key passphrase fail 3
openssl_certificate:
path: '{{ output_dir }}/cert_noext.pem'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
provider: assertonly
ignore_errors: yes
register: passphrase_error_3
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

@ -151,4 +151,39 @@
ownca_digest: sha256
register: ownca_certificate_ecc
- name: Generate ownca certificate (failed passphrase 1)
openssl_certificate:
path: '{{ output_dir }}/ownca_cert_pw1.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
ownca_path: '{{ output_dir }}/ca_cert.pem'
ownca_privatekey_path: '{{ output_dir }}/ca_privatekey.pem'
ownca_privatekey_passphrase: hunter2
provider: ownca
ownca_digest: sha256
ignore_errors: yes
register: passphrase_error_1
- name: Generate ownca certificate (failed passphrase 2)
openssl_certificate:
path: '{{ output_dir }}/ownca_cert_pw1.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
ownca_path: '{{ output_dir }}/ca_cert.pem'
ownca_privatekey_path: '{{ output_dir }}/privatekeypw.pem'
ownca_privatekey_passphrase: wrong_password
provider: ownca
ownca_digest: sha256
ignore_errors: yes
register: passphrase_error_2
- name: Generate ownca certificate (failed passphrase 3)
openssl_certificate:
path: '{{ output_dir }}/ownca_cert_pw3.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
ownca_path: '{{ output_dir }}/ca_cert.pem'
ownca_privatekey_path: '{{ output_dir }}/privatekeypw.pem'
provider: ownca
ownca_digest: sha256
ignore_errors: yes
register: passphrase_error_3
- import_tasks: ../tests/validate_ownca.yml

@ -3,6 +3,13 @@
openssl_privatekey:
path: '{{ output_dir }}/privatekey.pem'
- name: Generate privatekey with password
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: auto
select_crypto_backend: cryptography
- name: Generate CSR
openssl_csr:
path: '{{ output_dir }}/csr.csr'
@ -157,4 +164,36 @@
selfsigned_digest: sha256
register: selfsigned_certificate_ecc
- name: Generate selfsigned certificate (failed passphrase 1)
openssl_certificate:
path: '{{ output_dir }}/cert_pw1.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
privatekey_path: '{{ output_dir }}/privatekey.pem'
privatekey_passphrase: hunter2
provider: selfsigned
selfsigned_digest: sha256
ignore_errors: yes
register: passphrase_error_1
- name: Generate selfsigned certificate (failed passphrase 2)
openssl_certificate:
path: '{{ output_dir }}/cert_pw2.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
privatekey_passphrase: wrong_password
provider: selfsigned
selfsigned_digest: sha256
ignore_errors: yes
register: passphrase_error_2
- name: Generate selfsigned certificate (failed passphrase 3)
openssl_certificate:
path: '{{ output_dir }}/cert_pw3.pem'
csr_path: '{{ output_dir }}/csr_ecc.csr'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
provider: selfsigned
selfsigned_digest: sha256
ignore_errors: yes
register: passphrase_error_3
- import_tasks: ../tests/validate_selfsigned.yml

@ -81,3 +81,13 @@
- ownca_cert_ecc_pubkey.stdout == privatekey_ecc_pubkey.stdout
# openssl 1.1.x adds a space between the output
- ownca_cert_ecc_issuer.stdout in ['CN=Example CA', 'CN = Example CA']
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

@ -82,3 +82,13 @@
assert:
that:
- cert_ecc_pubkey.stdout == privatekey_ecc_pubkey.stdout
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

@ -241,3 +241,36 @@
select_crypto_backend: '{{ select_crypto_backend }}'
register: country_fail_4
ignore_errors: yes
- name: Generate privatekey with password
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: auto
select_crypto_backend: cryptography
- name: Generate publickey - PEM format
openssl_csr:
path: '{{ output_dir }}/csr_pw1.csr'
privatekey_path: '{{ output_dir }}/privatekey.pem'
privatekey_passphrase: hunter2
select_crypto_backend: '{{ select_crypto_backend }}'
ignore_errors: yes
register: passphrase_error_1
- name: Generate publickey - PEM format
openssl_csr:
path: '{{ output_dir }}/csr_pw2.csr'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
privatekey_passphrase: wrong_password
select_crypto_backend: '{{ select_crypto_backend }}'
ignore_errors: yes
register: passphrase_error_2
- name: Generate publickey - PEM format
openssl_csr:
path: '{{ output_dir }}/csr_pw3.csr'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
select_crypto_backend: '{{ select_crypto_backend }}'
ignore_errors: yes
register: passphrase_error_3

@ -109,3 +109,13 @@
- country_idempotent_2 is not changed
- country_idempotent_3 is not changed
- country_fail_4 is failed
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

@ -53,6 +53,45 @@
action: 'parse'
state: 'present'
- name: Generate privatekey with password
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: auto
select_crypto_backend: cryptography
- name: 'Generate PKCS#12 file (password fail 1)'
openssl_pkcs12:
path: "{{ output_dir }}/ansible_pw1.p12"
friendly_name: 'abracadabra'
privatekey_path: "{{ output_dir }}/ansible_pkey.pem"
privatekey_passphrase: hunter2
certificate_path: "{{ output_dir }}/ansible.crt"
state: present
ignore_errors: yes
register: passphrase_error_1
- name: 'Generate PKCS#12 file (password fail 2)'
openssl_pkcs12:
path: "{{ output_dir }}/ansible_pw2.p12"
friendly_name: 'abracadabra'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
privatekey_passphrase: wrong_password
certificate_path: "{{ output_dir }}/ansible.crt"
state: present
ignore_errors: yes
register: passphrase_error_2
- name: 'Generate PKCS#12 file (password fail 3)'
openssl_pkcs12:
path: "{{ output_dir }}/ansible_pw3.p12"
friendly_name: 'abracadabra'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
certificate_path: "{{ output_dir }}/ansible.crt"
state: present
ignore_errors: yes
register: passphrase_error_3
- import_tasks: ../tests/validate.yml
always:

@ -14,3 +14,13 @@
- p12_standard.mode == '0400'
- p12_force.changed
- p12_force_and_mode.mode == '0644' and p12_force_and_mode.changed
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

@ -142,3 +142,39 @@
loop_control:
label: "{{ item.curve }}"
register: privatekey_ecc_idempotency
- name: Generate privatekey with passphrase
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}"
select_crypto_backend: '{{ select_crypto_backend }}'
register: passphrase_1
- name: Generate privatekey with passphrase (idempotent)
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}"
select_crypto_backend: '{{ select_crypto_backend }}'
register: passphrase_2
- name: Regenerate privatekey without passphrase
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
select_crypto_backend: '{{ select_crypto_backend }}'
register: passphrase_3
- name: Regenerate privatekey without passphrase (idempotent)
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
select_crypto_backend: '{{ select_crypto_backend }}'
register: passphrase_4
- name: Regenerate privatekey with passphrase
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: "{{ 'aes256' if select_crypto_backend == 'pyopenssl' else 'auto' }}"
select_crypto_backend: '{{ select_crypto_backend }}'
register: passphrase_5

@ -104,3 +104,12 @@
when: "'skip_reason' not in item"
loop_control:
label: "{{ item.item.curve }}"
- name: Validate passphrase changing
assert:
that:
- passphrase_1 is changed
- passphrase_2 is not changed
- passphrase_3 is changed
- passphrase_4 is not changed
- passphrase_5 is changed

@ -78,6 +78,36 @@
path: '{{ output_dir }}/publickey5.pub'
privatekey_path: '{{ output_dir }}/privatekey5.pem'
- name: Generate privatekey with password
openssl_privatekey:
path: '{{ output_dir }}/privatekeypw.pem'
passphrase: hunter2
cipher: auto
select_crypto_backend: cryptography
- name: Generate publickey - PEM format (failed passphrase 1)
openssl_publickey:
path: '{{ output_dir }}/publickey_pw1.pub'
privatekey_path: '{{ output_dir }}/privatekey.pem'
privatekey_passphrase: hunter2
ignore_errors: yes
register: passphrase_error_1
- name: Generate publickey - PEM format (failed passphrase 2)
openssl_publickey:
path: '{{ output_dir }}/publickey_pw2.pub'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
privatekey_passphrase: wrong_password
ignore_errors: yes
register: passphrase_error_2
- name: Generate publickey - PEM format (failed passphrase 3)
openssl_publickey:
path: '{{ output_dir }}/publickey_pw3.pub'
privatekey_path: '{{ output_dir }}/privatekeypw.pem'
ignore_errors: yes
register: passphrase_error_3
- import_tasks: ../tests/validate.yml
when: pyopenssl_version.stdout is version('16.0.0', '>=')

@ -96,3 +96,13 @@
assert:
that:
- publickey5_pubkey.stdout == privatekey5_pubkey.stdout
- name:
assert:
that:
- passphrase_error_1 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_1.msg"
- passphrase_error_2 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_2.msg"
- passphrase_error_3 is failed
- "'assphrase' in passphrase_error_1.msg or 'assword' in passphrase_error_3.msg"

Loading…
Cancel
Save