mirror of
https://github.com/ansible/awx.git
synced 2026-03-21 02:47:35 -02:30
Add support for detecting encrypted openssh format private keys. Fixes https://trello.com/c/ZeVOXN5U
This commit is contained in:
committed by
Matthew Jones
parent
3b96c3ca52
commit
f0010cc574
@@ -158,7 +158,12 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique):
|
|||||||
ssh_key_data = decrypt_field(self, 'ssh_key_data')
|
ssh_key_data = decrypt_field(self, 'ssh_key_data')
|
||||||
else:
|
else:
|
||||||
ssh_key_data = self.ssh_key_data
|
ssh_key_data = self.ssh_key_data
|
||||||
return 'ENCRYPTED' in ssh_key_data
|
try:
|
||||||
|
key_data = self._validate_ssh_private_key(ssh_key_data)
|
||||||
|
except ValidationError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return bool(key_data['key_enc'])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def needs_ssh_key_unlock(self):
|
def needs_ssh_key_unlock(self):
|
||||||
@@ -231,27 +236,52 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique):
|
|||||||
"""Validate that the given SSH private key or certificate is,
|
"""Validate that the given SSH private key or certificate is,
|
||||||
in fact, valid.
|
in fact, valid.
|
||||||
"""
|
"""
|
||||||
cert = ''
|
# Map the X in BEGIN X PRIVATE KEY to the key type (ssh-keygen -t).
|
||||||
|
# Tower jobs using OPENSSH format private keys may still fail if the
|
||||||
|
# system SSH implementation lacks support for this format.
|
||||||
|
key_types = {
|
||||||
|
'RSA': 'rsa',
|
||||||
|
'DSA': 'dsa',
|
||||||
|
'EC': 'ecdsa',
|
||||||
|
'OPENSSH': 'ed25519',
|
||||||
|
'': 'rsa1',
|
||||||
|
}
|
||||||
|
# Key properties to return if valid.
|
||||||
|
key_data = {
|
||||||
|
'key_type': None, # Key type (from above mapping).
|
||||||
|
'key_seg': '', # Key segment (all text including begin/end).
|
||||||
|
'key_b64': '', # Key data as base64.
|
||||||
|
'key_bin': '', # Key data as binary.
|
||||||
|
'key_enc': None, # Boolean, whether key is encrypted.
|
||||||
|
'cert_seg': '', # Cert segment (all text including begin/end).
|
||||||
|
'cert_b64': '', # Cert data as base64.
|
||||||
|
'cert_bin': '', # Cert data as binary.
|
||||||
|
}
|
||||||
data = data.strip()
|
data = data.strip()
|
||||||
validation_error = ValidationError('Invalid private key')
|
validation_error = ValidationError('Invalid private key')
|
||||||
|
|
||||||
# Set up the valid private key header and footer.
|
|
||||||
begin_re = r'(-{4,})\s*BEGIN\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})'
|
|
||||||
end_re = r'(-{4,})\s*END\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})'
|
|
||||||
|
|
||||||
# Sanity check: We may potentially receive a full PEM certificate,
|
# Sanity check: We may potentially receive a full PEM certificate,
|
||||||
# and we want to accept these.
|
# and we want to accept these.
|
||||||
cert_begin_re = r'(-{4,})\s*BEGIN\s+CERTIFICATE\s*(-{4,})'
|
cert_begin_re = r'(-{4,})\s*BEGIN\s+CERTIFICATE\s*(-{4,})'
|
||||||
cert_end_re = r'(-{4,})\s*END\s+CERTIFICATE\s*(-{4,})'
|
cert_end_re = r'(-{4,})\s*END\s+CERTIFICATE\s*(-{4,})'
|
||||||
cert_begin_match = re.search(cert_begin_re, data)
|
cert_begin_match = re.search(cert_begin_re, data)
|
||||||
if cert_begin_match:
|
cert_end_match = re.search(cert_end_re, data)
|
||||||
cert_end_match = re.search(cert_end_re, data)
|
if cert_begin_match and not cert_end_match:
|
||||||
if not cert_end_match:
|
raise validation_error
|
||||||
|
elif not cert_begin_match and cert_end_match:
|
||||||
|
raise validation_error
|
||||||
|
elif cert_begin_match and cert_end_match:
|
||||||
|
cert_dashes = set([cert_begin_match.groups()[0], cert_begin_match.groups()[1],
|
||||||
|
cert_end_match.groups()[0], cert_end_match.groups()[1]])
|
||||||
|
if len(cert_dashes) != 1:
|
||||||
raise validation_error
|
raise validation_error
|
||||||
cert = data[cert_begin_match.start():cert_end_match.end()]
|
key_data['cert_seg'] = data[cert_begin_match.start():cert_end_match.end()]
|
||||||
|
|
||||||
# Find the private key, and also ensure that it internally matches
|
# Find the private key, and also ensure that it internally matches
|
||||||
# itself.
|
# itself.
|
||||||
|
# Set up the valid private key header and footer.
|
||||||
|
begin_re = r'(-{4,})\s*BEGIN\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})'
|
||||||
|
end_re = r'(-{4,})\s*END\s+([A-Z0-9]+)?\s*PRIVATE\sKEY\s*(-{4,})'
|
||||||
begin_match = re.search(begin_re, data)
|
begin_match = re.search(begin_re, data)
|
||||||
end_match = re.search(end_re, data)
|
end_match = re.search(end_re, data)
|
||||||
if not begin_match or not end_match:
|
if not begin_match or not end_match:
|
||||||
@@ -265,18 +295,22 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique):
|
|||||||
raise validation_error
|
raise validation_error
|
||||||
if begin_match.groups()[1] != end_match.groups()[1]:
|
if begin_match.groups()[1] != end_match.groups()[1]:
|
||||||
raise validation_error
|
raise validation_error
|
||||||
line_continues = False
|
key_type = begin_match.groups()[1]
|
||||||
|
try:
|
||||||
|
key_data['key_type'] = key_types[key_type]
|
||||||
|
except KeyError:
|
||||||
|
raise ValidationError('Invalid private key: unsupported type %s' % key_type)
|
||||||
|
|
||||||
# The private key data begins and ends with the private key.
|
# The private key data begins and ends with the private key.
|
||||||
data = data[begin_match.start():end_match.end()]
|
key_data['key_seg'] = data[begin_match.start():end_match.end()]
|
||||||
|
|
||||||
# Establish that we are able to base64 decode the private key;
|
# Establish that we are able to base64 decode the private key;
|
||||||
# if we can't, then it's not a valid key.
|
# if we can't, then it's not a valid key.
|
||||||
#
|
#
|
||||||
# If we got a certificate, validate that also, in the same way.
|
# If we got a certificate, validate that also, in the same way.
|
||||||
header_re = re.compile(r'^(.+?):\s*?(.+?)(\\??)$')
|
header_re = re.compile(r'^(.+?):\s*?(.+?)(\\??)$')
|
||||||
base64_data = ''
|
for segment_name in ('cert', 'key'):
|
||||||
for segment_to_validate in (cert, data):
|
segment_to_validate = key_data['%s_seg' % segment_name]
|
||||||
# If we have nothing; skip this one.
|
# If we have nothing; skip this one.
|
||||||
# We've already validated that we have a private key above,
|
# We've already validated that we have a private key above,
|
||||||
# so we don't need to do it again.
|
# so we don't need to do it again.
|
||||||
@@ -284,6 +318,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Ensure that this segment is valid base64 data.
|
# Ensure that this segment is valid base64 data.
|
||||||
|
base64_data = ''
|
||||||
|
line_continues = False
|
||||||
lines = segment_to_validate.splitlines()
|
lines = segment_to_validate.splitlines()
|
||||||
for line in lines[1:-1]:
|
for line in lines[1:-1]:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -301,9 +337,23 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique):
|
|||||||
decoded_data = base64.b64decode(base64_data)
|
decoded_data = base64.b64decode(base64_data)
|
||||||
if not decoded_data:
|
if not decoded_data:
|
||||||
raise validation_error
|
raise validation_error
|
||||||
|
key_data['%s_b64' % segment_name] = base64_data
|
||||||
|
key_data['%s_bin' % segment_name] = decoded_data
|
||||||
except TypeError:
|
except TypeError:
|
||||||
raise validation_error
|
raise validation_error
|
||||||
|
|
||||||
|
# Determine if key is encrypted.
|
||||||
|
if key_data['key_type'] == 'ed25519':
|
||||||
|
# See https://github.com/openssh/openssh-portable/blob/master/sshkey.c#L3218
|
||||||
|
# Decoded key data starts with magic string (null-terminated), four byte
|
||||||
|
# length field, followed by the ciphername -- if ciphername is anything
|
||||||
|
# other than 'none' the key is encrypted.
|
||||||
|
key_data['key_enc'] = not bool(key_data['key_bin'].startswith('openssh-key-v1\x00\x00\x00\x00\x04none'))
|
||||||
|
else:
|
||||||
|
key_data['key_enc'] = bool('ENCRYPTED' in key_data['key_seg'])
|
||||||
|
|
||||||
|
return key_data
|
||||||
|
|
||||||
def clean_ssh_key_data(self):
|
def clean_ssh_key_data(self):
|
||||||
if self.pk:
|
if self.pk:
|
||||||
ssh_key_data = decrypt_field(self, 'ssh_key_data')
|
ssh_key_data = decrypt_field(self, 'ssh_key_data')
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from django.utils.timezone import now
|
|||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from awx.main.tests.base import BaseTransactionTest
|
from awx.main.tests.base import BaseTransactionTest
|
||||||
from awx.main.tests.tasks import TEST_SSH_KEY_DATA, TEST_SSH_KEY_DATA_LOCKED, TEST_SSH_KEY_DATA_UNLOCK
|
from awx.main.tests.tasks import TEST_SSH_KEY_DATA, TEST_SSH_KEY_DATA_LOCKED, TEST_SSH_KEY_DATA_UNLOCK, TEST_OPENSSH_KEY_DATA, TEST_OPENSSH_KEY_DATA_LOCKED
|
||||||
from awx.main.utils import decrypt_field, update_scm_url
|
from awx.main.utils import decrypt_field, update_scm_url
|
||||||
|
|
||||||
TEST_PLAYBOOK = '''- hosts: mygroup
|
TEST_PLAYBOOK = '''- hosts: mygroup
|
||||||
@@ -578,6 +578,20 @@ class ProjectsTest(BaseTransactionTest):
|
|||||||
data['ssh_key_data'] = TEST_SSH_KEY_DATA
|
data['ssh_key_data'] = TEST_SSH_KEY_DATA
|
||||||
self.post(url, data, expect=201)
|
self.post(url, data, expect=201)
|
||||||
|
|
||||||
|
# Test with OpenSSH format private key.
|
||||||
|
with self.current_user(self.super_django_user):
|
||||||
|
data = dict(name='openssh-unlocked', user=self.super_django_user.pk, kind='ssh',
|
||||||
|
ssh_key_data=TEST_OPENSSH_KEY_DATA)
|
||||||
|
self.post(url, data, expect=201)
|
||||||
|
|
||||||
|
# Test with OpenSSH format private key that requires passphrase.
|
||||||
|
with self.current_user(self.super_django_user):
|
||||||
|
data = dict(name='openssh-locked', user=self.super_django_user.pk, kind='ssh',
|
||||||
|
ssh_key_data=TEST_OPENSSH_KEY_DATA_LOCKED)
|
||||||
|
self.post(url, data, expect=400)
|
||||||
|
data['ssh_key_unlock'] = TEST_SSH_KEY_DATA_UNLOCK
|
||||||
|
self.post(url, data, expect=201)
|
||||||
|
|
||||||
# Test post as organization admin where team is part of org, but user
|
# Test post as organization admin where team is part of org, but user
|
||||||
# creating credential is not a member of the team. UI may pass user
|
# creating credential is not a member of the team. UI may pass user
|
||||||
# as an empty string instead of None.
|
# as an empty string instead of None.
|
||||||
|
|||||||
@@ -289,6 +289,65 @@ wwoi+P4JlJF6ZuhuDv6mhmBCSdXdc1bvimvdpOljhThr+cG5mM08iqWGKdA665cw
|
|||||||
-----END RSA PRIVATE KEY-----
|
-----END RSA PRIVATE KEY-----
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
TEST_OPENSSH_KEY_DATA = '''-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
|
'''
|
||||||
|
|
||||||
|
TEST_OPENSSH_KEY_DATA_LOCKED = '''-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
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
|
||||||
|
Vg9TtUBqfKIpRQikPSjm7vpY/Xnbc=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
|
'''
|
||||||
|
|
||||||
TEST_SSH_CERT_KEY = """-----BEGIN CERTIFICATE-----
|
TEST_SSH_CERT_KEY = """-----BEGIN CERTIFICATE-----
|
||||||
MIIDNTCCAh2gAwIBAgIBATALBgkqhkiG9w0BAQswSTEWMBQGA1UEAwwNV2luZG93
|
MIIDNTCCAh2gAwIBAgIBATALBgkqhkiG9w0BAQswSTEWMBQGA1UEAwwNV2luZG93
|
||||||
cyBBenVyZTELMAkGA1UEBhMCVVMxIjAgBgkqhkiG9w0BCQEWE2x1a2VAc25lZXJp
|
cyBBenVyZTELMAkGA1UEBhMCVVMxIjAgBgkqhkiG9w0BCQEWE2x1a2VAc25lZXJp
|
||||||
|
|||||||
Reference in New Issue
Block a user