diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 5530c4670d..885538cd26 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -158,7 +158,12 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): ssh_key_data = decrypt_field(self, 'ssh_key_data') else: 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 def needs_ssh_key_unlock(self): @@ -231,27 +236,52 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): """Validate that the given SSH private key or certificate is, 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() 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, # and we want to accept these. cert_begin_re = r'(-{4,})\s*BEGIN\s+CERTIFICATE\s*(-{4,})' cert_end_re = r'(-{4,})\s*END\s+CERTIFICATE\s*(-{4,})' cert_begin_match = re.search(cert_begin_re, data) - if cert_begin_match: - cert_end_match = re.search(cert_end_re, data) - if not cert_end_match: + cert_end_match = re.search(cert_end_re, data) + if cert_begin_match and 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 - 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 # 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) end_match = re.search(end_re, data) if not begin_match or not end_match: @@ -265,18 +295,22 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): raise validation_error if begin_match.groups()[1] != end_match.groups()[1]: 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. - 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; # if we can't, then it's not a valid key. # # If we got a certificate, validate that also, in the same way. header_re = re.compile(r'^(.+?):\s*?(.+?)(\\??)$') - base64_data = '' - for segment_to_validate in (cert, data): + for segment_name in ('cert', 'key'): + segment_to_validate = key_data['%s_seg' % segment_name] # If we have nothing; skip this one. # We've already validated that we have a private key above, # so we don't need to do it again. @@ -284,6 +318,8 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): continue # Ensure that this segment is valid base64 data. + base64_data = '' + line_continues = False lines = segment_to_validate.splitlines() for line in lines[1:-1]: line = line.strip() @@ -301,9 +337,23 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): decoded_data = base64.b64decode(base64_data) if not decoded_data: raise validation_error + key_data['%s_b64' % segment_name] = base64_data + key_data['%s_bin' % segment_name] = decoded_data except TypeError: 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): if self.pk: ssh_key_data = decrypt_field(self, 'ssh_key_data') diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index 3d4ce08b5d..6628dd3714 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -21,7 +21,7 @@ from django.utils.timezone import now # AWX from awx.main.models import * # noqa 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 TEST_PLAYBOOK = '''- hosts: mygroup @@ -578,6 +578,20 @@ class ProjectsTest(BaseTransactionTest): data['ssh_key_data'] = TEST_SSH_KEY_DATA 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 # creating credential is not a member of the team. UI may pass user # as an empty string instead of None. diff --git a/awx/main/tests/tasks.py b/awx/main/tests/tasks.py index a14b6d02a7..20cefc1d5f 100644 --- a/awx/main/tests/tasks.py +++ b/awx/main/tests/tasks.py @@ -289,6 +289,65 @@ wwoi+P4JlJF6ZuhuDv6mhmBCSdXdc1bvimvdpOljhThr+cG5mM08iqWGKdA665cw -----END RSA PRIVATE KEY----- ''' +TEST_OPENSSH_KEY_DATA = '''-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEA1AZAwUJUiLmOXjbO5q2ZE5DF+gMpPKe8NEr12FpvOaJr1Nz/DNpf +FE/VbssOJ4CRD/6MItlPSG2pC1Cv3AYSL7NBc0YCMlBR/P/nLI8pLAzU3p3KRYvR+R6cMW +3nMcxyB1UUgzXY9dTVFIyejOsm7stGuNfdDTTLBE2vTDz6CyzxxSALEOdYut5cfeTUuG7d +nP01K3JiaHjHaXDmwraRR/JlitylaZUnSZ+/b9WCMX5FyeJ6CnGdvcCuvMK0iNjZ8R+PxP +xJBM5AlJC5J6qa8YmeaQ6lA/2S+/wGuhJmocmiXiLFy9IzIPnQiR+h8DqStp4xp245UQxe +TIGSMmq8DQAAA9A4FMRSOBTEUgAAAAdzc2gtcnNhAAABAQDUBkDBQlSIuY5eNs7mrZkTkM +X6Ayk8p7w0SvXYWm85omvU3P8M2l8UT9Vuyw4ngJEP/owi2U9IbakLUK/cBhIvs0FzRgIy +UFH8/+csjyksDNTencpFi9H5HpwxbecxzHIHVRSDNdj11NUUjJ6M6ybuy0a4190NNMsETa +9MPPoLLPHFIAsQ51i63lx95NS4bt2c/TUrcmJoeMdpcObCtpFH8mWK3KVplSdJn79v1YIx +fkXJ4noKcZ29wK68wrSI2NnxH4/E/EkEzkCUkLknqprxiZ5pDqUD/ZL7/Aa6EmahyaJeIs +XL0jMg+dCJH6HwOpK2njGnbjlRDF5MgZIyarwNAAAAAwEAAQAAAQAp8orBMYRUAJIgJavN +i67rZgslKZbw/yaHGgWFpm628mFvHcIAIvwIorrRTq8gNZl9lpjXFDNRWxDEwlPorfLPKS +Hb0pAAsE9oRKDR+gjlRCyhVop8M+t45At25A2HlrFArh5+zxp7mH4HsMJ1ktiDCgiV7W84 +e6dm1I/H/5BgwUlTNoVOGPrU183gqRsHIICjfmnjl2ObJoly+MTrAy7E9rSmsO+pHKl8z0 +XODWh3mo+EkCoYrK6kP96Jy3BepSmbZMROEsctS7Mkzu6QdnfTY3QqIzENYtTGJuAGktGj +su4MHP8hbj+TznNkFeZdmIC0uTnIKu1uquwuFF1HPZiBAAAAgACX9xPKS2J04WXpQag+JS +06n2zSuBHW7Kq4q/LMydoTRd8Quf6u6eivSBrl7H779LCtGCIZqJAslvWOyPyz2CohcCBU +emubiHcUA+aN7R9E0tyitwWraJjMIwpQ7+AbgdsLsuxozNeccSrr0tva2c5y9x7YGBcIdC +UJDt4xnBi7AAAAgQDz771v8Mb18kq5W+inDcYPFUNXGtNfeYZEOhYFpxunFnYwTEAG0Xnh +YpQXOAFZ2q5mkFQHMl4cOKwoAlaP0dM4v0JKPjFDLvGisEu95fnivj4YAMP/UHgKKxBbqW +HPUhg3adAmIJ9z9u/VmTErbVklcKWlyZuTUkxeQ/BJmSIRUQAAAIEA3oKAzdDURjy8zxLX +gBLCPdi8AxCiqQJBCsGxXCgKtZewset1XJHIN9ryfb4QSZFkSOlm/LgdeGtS8Or0GNPRYd +hgnUCF0LkEsDQ7HzPZYujLrAwjumvGQH6ORp5vRh0tQb93o4e1/A2vpdSKeH7gCe/jfUSY +h7dFGNoAI4cF7/0AAAAUcm9vdEBwaWxsb3cuaXhtbS5uZXQBAgMEBQYH +-----END OPENSSH PRIVATE KEY----- +''' + +TEST_OPENSSH_KEY_DATA_LOCKED = '''-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABALaWMfjc +hSvC7aXxQs1ZDiAAAAEAAAAAEAAAEXAAAAB3NzaC1yc2EAAAADAQABAAABAQDEDWKwZD8+ +h+2gZZKna8dy2QL4jJxM1eLGDcQDnuip1ixhaf5MT5T6BMploXXHs1pfuwx8yTQ6Ts/VJp +WX6cuHQg8sPGM3P7HNGUqs9q/EQfrrRxz555uL08CRaS6FjM/6x9iolNhHU910Wlg+R+ZS +xiMrrY/s03EiEChsAWTbwBGqTopGC2xMFgIxINoQtTFXv7MtCbDfl8aWKQRDmzkLvwT07N +ycj2kqADqoukD/2bQvPrW6FIZPJPpAdeAe2SZbf/y92NgVz/glOdtjaJp3oqn1QHrOA9/k +XgXOjgVQUbzX7qyLWenxM138VsRKUJZeROaHt1MWApLrLtKQ36SrAAAD0A+PODJjfeKm3U +JknlSYD7fFh6bVZGwG6LnLMtobs0elOfj2+sdg+hOVqyrA0rPOHES5yGKslTc/wRkRQ95m +dBleAyTDIOQ90IqDxT3lsNQwpscsFKPYKGmaUvZLLk4aNY1GeANtByXwTsjetVqn8Uo59A +zu6phX8Aagn2h0qxQwBnDjlzsXf6g5H7UPZd/t1dYr1NfVP6KWJrg0jivAI8tzO2HcM9W2 +cyOaodBw/6TsJNKvDV714Z+apvrNDEufBUsovKjAna2BDVZIhTCg5mYm0Dks8JStQrG2S1 +Yk8EM3+fpo8uMoHVz1jbYC8UX12pwIU67MhUn24KBxqulCYaTMsrLFkNWk6vKgwib+sIa4 +i1Bij1Zd0rdJWypQqTc2Oj3bBSYM47AksMXcKVpuNnFLh4+eokpQzbtIYpRqhOTh1Fky7z +xkhTgWVvf/F19M9t1bz3Rm1/t5I75Ag9qfKWs06j+VVfXnDt5v5hYAEhoJjMzSjgKaqc5g +YndeWeUwO6Vijt4XpkB8+0R7Kptsh9L0UUsNIcRoGcqrM8IUVb3D8vPWppPlj9d6LB+FCo +Cy1JlscnpBb8AQy9QMvrJTHKOyjRcenVxILPiN8PypIC008jvqpDzKimAxM4IMuA7AWE6w +j5+CzfUhDAJGdl2qH/nVc7GFUtz8bVA/v9Zkawg2MLcafgGollbLcTbKwDFcenQuyHT+Hj +uDm2f0oV/EDKFqLijlV8vcLBNUZoxY/L62Vora1jlqnapq2Z/AM9NicoELYNe21ReJ5dxM +7Pk/QdSrZjQzxoHf8uBDpb7x/KyfnSdf8GmdGCxoJ5mcepwD4tROMFC104tN0STJpdGVSm +Q5ZG1JDN7F9iJCCAwyulWH/XxTzFYnQ84199cQeV/M9rXXgbXa8ApAung6X9j8y1fcw9Lw +wV1aP06bCNgM0U50PiZ54HXwzVt+Ghs06TEF4/ZQiIgNJxdw0HFxAJj8qHqUCHuSmvBgnN +qRW/uruItwpXLaL00EHu7rAFlBi1BnnetI+D12ls04mlyTUFFM5v520B5zPV+5If2hx91w +C6Oxl1Wsp3gPkK2yiuy8qcrvoEoJ25TeEhUGEAPWx2OuQJO/Lpq9aF/JJoqGwnBaXdCsi+ +5ig+ZMq5GKQtyydzyXImjlNEUH1w2prRDiGVEufANA5LSLCtqOLgDzXS62WUBjJBrQJVAM +YpWz1tiZQoyv1RT3Y0O0Vwe2Z5AK3fVM0I5jWdiLrIErtcR4ULa6T56QtA52DufhKzINTR +Vg9TtUBqfKIpRQikPSjm7vpY/Xnbc= +-----END OPENSSH PRIVATE KEY----- +''' + TEST_SSH_CERT_KEY = """-----BEGIN CERTIFICATE----- MIIDNTCCAh2gAwIBAgIBATALBgkqhkiG9w0BAQswSTEWMBQGA1UEAwwNV2luZG93 cyBBenVyZTELMAkGA1UEBhMCVVMxIjAgBgkqhkiG9w0BCQEWE2x1a2VAc25lZXJp