diff --git a/keycert-demo.py b/keycert-demo.py index d7b4d3e..e1d22bb 100644 --- a/keycert-demo.py +++ b/keycert-demo.py @@ -59,6 +59,7 @@ def ssh_load_key(options, open_method=None): path = options.file output_format = options.type password = options.password + key_password = options.key_password if not path: return print_error('No path specified') @@ -72,14 +73,14 @@ def ssh_load_key(options, open_method=None): key = Key.fromString(key_content, passphrase=password) if key.isPublic(): - to_string = key.toString(output_format) + to_string = key.toString(output_format, extra=key_password) else: - to_string = key.toString(output_format) + to_string = key.toString(output_format, extra=key_password) result = '%r\nKey type %s\n\n%s' % ( key, Key.getKeyFormat(key_content), - to_string, + to_string.decode('utf-8'), ) return result @@ -164,7 +165,13 @@ def ssh_verify_data(options): '--password', metavar='PASSWORD', default=None, - help='Option password or commented used when re-encoding the loaded key.' + help='Option password used when loading the key.' + ) +sub.add_argument( + '--key-password', + metavar='PASSWORD', + default=None, + help='Option password used when writing key.' ) sub.set_defaults(handler=ssh_load_key) diff --git a/pavement.py b/pavement.py index 299f4b2..f60a4b6 100644 --- a/pavement.py +++ b/pavement.py @@ -200,7 +200,12 @@ def lint(): except SystemExit as pyflakes_exit: pass - sys.argv.extend(['--ignore=E741', '--hang-closing']) + sys.argv.extend([ + '--ignore=E741', + '--ignore=E741', + '--hang-closing', + '--max-line-length=80', + ]) pycodestyle_exit = pycodestyle_main() sys.exit(pyflakes_exit.code or pycodestyle_exit) diff --git a/src/chevah_keycert/__init__.py b/src/chevah_keycert/__init__.py index 25d61c3..82a5cd9 100644 --- a/src/chevah_keycert/__init__.py +++ b/src/chevah_keycert/__init__.py @@ -7,6 +7,8 @@ import base64 import inspect +import cryptography.utils + def _path(path, encoding='utf-8'): if sys.platform.startswith('win'): @@ -32,7 +34,7 @@ def native_string(string): for member in ['Callable', 'Iterable', 'Mapping', 'Sequence']: if not hasattr(collections, member): setattr(collections, member, getattr(collections.abc, member)) -import cryptography.utils + if not hasattr(cryptography.utils, 'int_from_bytes'): cryptography.utils.int_from_bytes = int.from_bytes diff --git a/src/chevah_keycert/ssh.py b/src/chevah_keycert/ssh.py index 0b30977..9be985e 100644 --- a/src/chevah_keycert/ssh.py +++ b/src/chevah_keycert/ssh.py @@ -20,6 +20,7 @@ import six import bcrypt +from argon2 import low_level from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization @@ -87,21 +88,21 @@ b'ecdsa-sha2-nistp384': ec.SECP384R1(), b'ecdsa-sha2-nistp521': ec.SECP521R1(), b'ecdsa-sha2-nistp192': ec.SECP192R1(), -} + } _secToNist = { 'secp256r1' : b'nistp256', 'secp384r1' : b'nistp384', 'secp521r1' : b'nistp521', 'secp192r1' : b'nistp192', -} + } _ecSizeTable = { 256: ec.SECP256R1(), 384: ec.SECP384R1(), 521: ec.SECP521R1(), -} + } class BadFingerPrintFormat(Exception): """ @@ -1900,8 +1901,8 @@ def _fromString_PRIVATE_SSHCOM(cls, data, passphrase): * mpint d * mpint n * mpint u - * mpint p * mpint q + * mpint p The payload for a DSA key: * uint32 0 @@ -1962,7 +1963,7 @@ def _fromString_PRIVATE_SSHCOM(cls, data, passphrase): try: payload, _ = common.getNS(key_data) if key_type == 'rsa': - e, d, n, u, p, q, rest = cls._unpackMPSSHCOM(payload, 6) + e, d, n, u, q, p, rest = cls._unpackMPSSHCOM(payload, 6) return cls._fromRSAComponents(n=n, e=e, d=d, p=p, q=q, u=u) if key_type == 'dsa': @@ -2070,8 +2071,8 @@ def _toString_SSHCOM_private(self, extra): self._packMPSSHCOM(data['d']) + self._packMPSSHCOM(data['n']) + self._packMPSSHCOM(data['u']) + - self._packMPSSHCOM(data['p']) + - self._packMPSSHCOM(data['q']) + self._packMPSSHCOM(data['q']) + + self._packMPSSHCOM(data['p']) ) elif type == 'DSA': type_signature = 'dl-modp{sign{dsa-nist-sha1},dh{plain}}' @@ -2152,8 +2153,8 @@ def _fromString_PRIVATE_PUTTY(cls, data, passphrase): * mpint n Private part RSA: * mpint d - * mpint q * mpint p + * mpint q * mpint u Pulic part DSA: @@ -2277,7 +2278,7 @@ def _fromString_PRIVATE_PUTTY(cls, data, passphrase): if key_type == 'ssh-rsa': e, n, _ = common.getMP(public_payload, count=2) - d, q, p, u, _ = common.getMP(private_blob, count=4) + d, p, q, u, _ = common.getMP(private_blob, count=4) return cls._fromRSAComponents(n=n, e=e, d=d, p=p, q=q, u=u) if key_type == 'ssh-dss': @@ -2367,8 +2368,8 @@ def _toString_PUTTY_private(self, extra): ) private_blob = ( common.MP(data['d']) + - common.MP(data['q']) + common.MP(data['p']) + + common.MP(data['q']) + common.MP(data['u']) ) elif key_type == b'ssh-dss': @@ -2486,8 +2487,8 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): * mpint n Private part RSA: * mpint d - * mpint q * mpint p + * mpint q * mpint u Pulic part DSA: @@ -2537,6 +2538,7 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): 'Unsupported key type: "%s"' % force_unicode(key_type[:30])) encryption_type = lines[1][11:].strip().lower() + private_offset = 0 if encryption_type == 'none': if passphrase: @@ -2545,6 +2547,9 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): raise BadKeyError( 'Unsupported encryption type: "%s"' % force_unicode( encryption_type[:30])) + else: + # Key is encrypted. + private_offset = 5 comment = lines[2][9:].strip() @@ -2562,8 +2567,9 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): force_unicode(key_type[:30]), force_unicode(public_type[:30]))) - # We skip 4 lines so far and the total public lines. - private_start_line = 4 + public_count + # We skip 4 lines so far and the total public lines and any option + # private key derivation parameters. + private_start_line = 4 + public_count + private_offset private_count = int(lines[private_start_line][15:].strip()) base64_content = ''.join(lines[ private_start_line + 1: @@ -2573,17 +2579,21 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): private_mac = lines[-1][12:].strip() - hmac_key = PUTTY_HMAC_KEY + # Default for non-encryption is empty HMAC key. + # THis is updated later if we have encrypted key. + hmac_key = b'' + encryption_key = None if encryption_type == 'aes256-cbc': if not passphrase: raise EncryptedKeyError( 'Passphrase must be provided for an encrypted key.') - hmac_key += passphrase - encryption_key = cls._getPuttyAES256EncryptionKey_v3(passphrase) + encryption_key, iv, hmac_key = cls._getPuttyAES256EncryptionKey_v3( + lines[4 + public_count:private_start_line], + passphrase) decryptor = Cipher( - algorithms.AES(encryption_key), - modes.CBC(b'\x00' * 16), + algorithms.AES256(encryption_key), + modes.CBC(iv), backend=default_backend() ).decryptor() private_blob = ( @@ -2597,8 +2607,8 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): common.NS(public_blob) + common.NS(private_blob) ) - hmac_key = sha1(hmac_key).digest() - computed_mac = hmac.new(hmac_key, hmac_data, sha1).hexdigest() + + computed_mac = hmac.new(hmac_key, hmac_data, sha256).hexdigest() if private_mac != computed_mac: if encryption_key: raise EncryptedKeyError('Bad password or HMAC mismatch.') @@ -2610,7 +2620,7 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): if key_type == 'ssh-rsa': e, n, _ = common.getMP(public_payload, count=2) - d, q, p, u, _ = common.getMP(private_blob, count=4) + d, p, q, u, _ = common.getMP(private_blob, count=4) return cls._fromRSAComponents(n=n, e=e, d=d, p=p, q=q, u=u) if key_type == 'ssh-dss': @@ -2639,6 +2649,63 @@ def _fromString_PRIVATE_PUTTY_V3(cls, data, passphrase): privateValue=privateValue, ) + @classmethod + def _getPuttyAES256EncryptionKey_v3(cls, headers, passphrase): + """ + Return (cipher key, IV, MAC key) used to decrypt the private key. + """ + parameters = cls._getPuttyEncryptionKeyParameters(headers) + + argon_type = low_level.Type.ID + if parameters['Key-Derivation'] == 'Argon2id': + argon_type = low_level.Type.ID + elif parameters['Key-Derivation'] == 'Argon2i': + argon_type = low_level.Type.I + elif parameters['Key-Derivation'] == 'Argon2d': + argon_type = low_level.Type.D + else: + raise BadKeyError('Key-Derivation algorithm not supported.') + + result = low_level.hash_secret_raw( + secret=passphrase, + salt=bytes.fromhex(parameters['Argon2-Salt']), + time_cost=int(parameters['Argon2-Passes']), + memory_cost=int(parameters['Argon2-Memory']), + parallelism=int(parameters['Argon2-Parallelism']), + type=argon_type, + # cipher key length + IV length + MAC key length + hash_len=80, + version=19, + ) + return ( + result[:32], + result[32:48], + result[48:], + ) + + @classmethod + def _getPuttyEncryptionKeyParameters(cls, headers): + """ + Return a dictionary with the key->value for key derivation headers. + Returned values are text. + """ + result = {} + for line in headers: + parts = line.split(':', 1) + result[parts[0].strip()] = parts[1].strip() + + expected_headers = set([ + 'Key-Derivation', + 'Argon2-Memory', + 'Argon2-Passes', + 'Argon2-Parallelism', + 'Argon2-Salt', + ]) + if expected_headers != set(result.keys()): + raise BadKeyError( + 'Putty v3 encrypted key has invalid key derivation headers.') + return result + def _toString_PUTTY_V3(self, comment=None, passphrase=None): """ Return a public or private Putty v3 string. @@ -2660,6 +2727,124 @@ def _toString_PUTTY_V3(self, comment=None, passphrase=None): else: return self._toString_PUTTY_V3_private(passphrase) + def _toString_PUTTY_V3_private(self, extra): + """ + Return the Putty private key representation. + + See fromString for Putty file format. + """ + aes_block_size = 16 + lines = [] + key_type = self.sshType() + comment = 'Exported by chevah-keycert.' + data = self.data() + + hmac_key = b'' + encryption_headers = [] + if extra: + encryption_type = 'aes256-cbc' + hmac_key += extra + else: + encryption_type = 'none' + + if key_type == b'ssh-rsa': + public_blob = ( + common.NS(key_type) + + common.MP(data['e']) + + common.MP(data['n']) + ) + private_blob = ( + common.MP(data['d']) + + common.MP(data['p']) + + common.MP(data['q']) + + common.MP(data['u']) + ) + elif key_type == b'ssh-dss': + public_blob = ( + common.NS(key_type) + + common.MP(data['p']) + + common.MP(data['q']) + + common.MP(data['g']) + + common.MP(data['y']) + ) + private_blob = common.MP(data['x']) + + elif key_type == b'ssh-ed25519': + public_blob = ( + common.NS(key_type) + + common.NS(data['a']) + ) + private_blob = common.NS(data['k']) + + elif key_type in _curveTable: + + curve_name = _secToNist[self._keyObject.curve.name] + encode_point = self._keyObject.public_key().public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + public_blob = ( + common.NS(key_type) + + common.NS(curve_name) + + common.NS(encode_point) + ) + private_blob = common.MP(data['privateValue']) + + else: # pragma: no cover + raise BadKeyError('Unsupported key type.') + + private_blob_plain = private_blob + private_blob_encrypted = private_blob + + if extra: + # Encryption is requested. + # Padding is required for encryption. + padding_size = -1 * ( + (len(private_blob) % aes_block_size) - aes_block_size) + private_blob_plain += b'\x00' * padding_size + + encryption_headers.append('Key-Derivation: Argon2id') + encryption_headers.append('Argon2-Memory: 8192') + encryption_headers.append('Argon2-Passes: 34') + encryption_headers.append('Argon2-Parallelism: 1') + encryption_headers.append('Argon2-Salt: {}'.format( + self.secureRandom(16).hex())) + encryption_key, iv, hmac_key = self._getPuttyAES256EncryptionKey_v3( + encryption_headers, extra) + encryptor = Cipher( + algorithms.AES256(encryption_key), + modes.CBC(iv), + backend=default_backend() + ).encryptor() + private_blob_encrypted = ( + encryptor.update(private_blob_plain) + encryptor.finalize()) + + public_lines = textwrap.wrap( + base64.b64encode(public_blob).decode('ascii'), 64) + private_lines = textwrap.wrap( + base64.b64encode(private_blob_encrypted).decode('ascii'), 64) + + hmac_data = ( + common.NS(key_type) + + common.NS(encryption_type) + + common.NS(comment) + + common.NS(public_blob) + + common.NS(private_blob_plain) + ) + hmac_key = sha256(hmac_key).digest() + private_mac = hmac.new(hmac_key, hmac_data, sha256).hexdigest() + + lines.append('PuTTY-User-Key-File-3: %s' % key_type.decode('ascii')) + lines.append('Encryption: %s' % encryption_type) + lines.extend(encryption_headers) + lines.append('Comment: %s' % comment) + lines.append('Public-Lines: %s' % len(public_lines)) + lines.extend(public_lines) + lines.append('Private-Lines: %s' % len(private_lines)) + lines.extend(private_lines) + lines.append('Private-MAC: %s' % private_mac) + return '\r\n'.join(lines).encode('utf-8') + @classmethod def _fromString_PUBLIC_X509_CERTIFICATE(cls, data): diff --git a/src/chevah_keycert/ssl.py b/src/chevah_keycert/ssl.py index bf762ac..58f2944 100644 --- a/src/chevah_keycert/ssl.py +++ b/src/chevah_keycert/ssl.py @@ -70,7 +70,8 @@ def _generate_self_csr_parser(sub_command, default_key_size): 'To mark usage as critical, prefix the values with `critical,`. ' 'For example: "critical,key-agreement,digital-signature".' ) % (', '.join( - list(_KEY_USAGE_STANDARD.keys()) + list(_KEY_USAGE_EXTENDED.keys()))), + list(_KEY_USAGE_STANDARD.keys()) + + list(_KEY_USAGE_EXTENDED.keys()))), ) sub_command.add_argument( diff --git a/src/chevah_keycert/tests/test_ssh.py b/src/chevah_keycert/tests/test_ssh.py index d0454db..55d5c07 100644 --- a/src/chevah_keycert/tests/test_ssh.py +++ b/src/chevah_keycert/tests/test_ssh.py @@ -454,8 +454,8 @@ Private-MAC: 6b753f6180f48d153a700c6734b46b2e52f1f7e9\r """ -PUTTY_ECDSA_SHA2_NISTP256_PRIVATE_NO_PASSWORD = ( -b"""PuTTY-User-Key-File-2: ecdsa-sha2-nistp256\r +PUTTY_ECDSA_SHA2_NISTP256_PRIVATE_NO_PASSWORD = b""" +PuTTY-User-Key-File-2: ecdsa-sha2-nistp256\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 3\r @@ -465,9 +465,10 @@ Private-Lines: 1\r AAAAIDe7fQUAaorrEkedXTSmXrCY4vabtFV7e4Z8xBSvty8Q\r Private-MAC: a84b17c5dead6fed8f474406929312d45c096dfc\r -""") +""".strip() -PUTTY_V3_ECDSA_SHA2_NISTP256_PRIVATE_NO_PASSWORD = b"""PuTTY-User-Key-File-3: ecdsa-sha2-nistp256\r +PUTTY_V3_ECDSA_SHA2_NISTP256_PRIVATE_NO_PASSWORD = b""" +PuTTY-User-Key-File-3: ecdsa-sha2-nistp256\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 3\r @@ -477,10 +478,10 @@ Private-Lines: 1\r AAAAIDe7fQUAaorrEkedXTSmXrCY4vabtFV7e4Z8xBSvty8Q\r Private-MAC: 6488b1e2221448122e8884df9622350510e7cd266d174b307104a15e5669afb5\r -""" +""".strip() -PUTTY_ECDSA_SHA2_NISTP384_PRIVATE_NO_PASSWORD = ( -b"""PuTTY-User-Key-File-2: ecdsa-sha2-nistp384\r +PUTTY_ECDSA_SHA2_NISTP384_PRIVATE_NO_PASSWORD = b""" +PuTTY-User-Key-File-2: ecdsa-sha2-nistp384\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 3\r @@ -491,9 +492,10 @@ AAAAMQCNcgWtnEeeTqFN383FBJdM90keHkJwproyLPgWQLlbZe+r8py0Pl7mUHvj\r SGmXUVc=\r Private-MAC: 1464df777d20427e2b99adb148ed4b8a1a839409\r -""") +""".strip() -PUTTY_V3_ECDSA_SHA2_NISTP384_PRIVATE_NO_PASSWORD = b"""PuTTY-User-Key-File-3: ecdsa-sha2-nistp384\r +PUTTY_V3_ECDSA_SHA2_NISTP384_PRIVATE_NO_PASSWORD = b""" +PuTTY-User-Key-File-3: ecdsa-sha2-nistp384\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 3\r @@ -504,10 +506,10 @@ AAAAMQCNcgWtnEeeTqFN383FBJdM90keHkJwproyLPgWQLlbZe+r8py0Pl7mUHvj\r SGmXUVc=\r Private-MAC: 73cdd8880d60561a21bc23017b191471354158e2f343e1b48e8dbe0e46b74067\r -""" +""".strip() -PUTTY_ECDSA_SHA2_NISTP521_PRIVATE_NO_PASSWORD = ( -b"""PuTTY-User-Key-File-2: ecdsa-sha2-nistp521\r +PUTTY_ECDSA_SHA2_NISTP521_PRIVATE_NO_PASSWORD = """P +uTTY-User-Key-File-2: ecdsa-sha2-nistp521\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 4\r @@ -519,9 +521,10 @@ AAAAQgE64XtEewBVYUz+sfojvHmsiwdT+2BBBw1IAcKuozuhsz8EkOEOBJGZqCBP\r B9pAqlHsVHQJF/uVpFbJFUnjEokJ4w==\r Private-MAC: e828d7207e0e73453005d606216ca36c64d1e304\r -""") +""".strip() -PUTTY_V3_ECDSA_SHA2_NISTP521_PRIVATE_NO_PASSWORD = b"""PuTTY-User-Key-File-3: ecdsa-sha2-nistp521\r +PUTTY_V3_ECDSA_SHA2_NISTP521_PRIVATE_NO_PASSWORD = b""" +PuTTY-User-Key-File-3: ecdsa-sha2-nistp521\r Encryption: none\r Comment: ecdsa-key-20210106\r Public-Lines: 4\r @@ -533,7 +536,7 @@ AAAAQgE64XtEewBVYUz+sfojvHmsiwdT+2BBBw1IAcKuozuhsz8EkOEOBJGZqCBP\r B9pAqlHsVHQJF/uVpFbJFUnjEokJ4w==\r Private-MAC: 3b713999a444c896d6ea7605aba44684693249d6de9b1a0775b60a9bf8e0f19a\r -""" +""".strip() class DummyOpenContext(object): @@ -605,23 +608,23 @@ def setUp(self): y=keydata.ECDatanistp256['y'], privateValue=keydata.ECDatanistp256['privateValue'], curve=keydata.ECDatanistp256['curve'] - )._keyObject + )._keyObject self.ecObj384 = keys.Key._fromECComponents( x=keydata.ECDatanistp384['x'], y=keydata.ECDatanistp384['y'], privateValue=keydata.ECDatanistp384['privateValue'], curve=keydata.ECDatanistp384['curve'] - )._keyObject + )._keyObject self.ecObj521 = keys.Key._fromECComponents( x=keydata.ECDatanistp521['x'], y=keydata.ECDatanistp521['y'], privateValue=keydata.ECDatanistp521['privateValue'], curve=keydata.ECDatanistp521['curve'] - )._keyObject + )._keyObject self.ed25519Obj = keys.Key._fromEd25519Components( a=keydata.Ed25519Data['a'], k=keydata.Ed25519Data['k'] - )._keyObject + )._keyObject self.rsaSignature = ( b"\x00\x00\x00\x07ssh-rsa\x00\x00\x01\x00~Y\xa3\xd7\xfdW\xc6pu@" b"\xd81\xa1S\xf3O\xdaE\xf4/\x1ex\x1d\xf1\x9a\xe1G3\xd9\xd6U\x1f" @@ -636,19 +639,12 @@ def setUp(self): b"\x86\xe5k\xe3\xce\xe0u\x1c\xeb\x93\x1aN\x88\xc9\x93Y\xc3.V\xb1L" b"44`C\xc7\xa66\xaf\xfa\x7f\x04Y\x92\xfa\xa4\x1a\x18%\x19\xd5 4^" b"\xb9rY\xba \x01\xf9.\x89%H\xbe\x1c\x83A\x96" - ) + ) self.dsaSignature = ( b'\x00\x00\x00\x07ssh-dss\x00\x00\x00(?\xc7\xeb\x86;\xd5TFA\xb4' b'\xdf\x0c\xc4E@4,d\xbc\t\xd9\xae\xdd[\xed-\x82nQ\x8cf\x9b\xe8\xe1' b'jrg\x84p<' - ) - self.oldSecureRandom = Key.secureRandom - Key.secureRandom = lambda me, x: '\xff' * x - - def tearDown(self): - Key.secureRandom = self.oldSecureRandom - del self.oldSecureRandom - super(TestKey, self).tearDown() + ) def assertBadKey(self, content, message): """ @@ -731,7 +727,7 @@ def test_equal(self): self.assertFalse(rsa1 == rsa3) self.assertFalse(rsa1 == dsa) self.assertFalse(rsa1 == object) - self.assertFalse(rsa1 == None) + self.assertFalse(rsa1 is None) def test_notEqual(self): """ @@ -745,8 +741,8 @@ def test_notEqual(self): self.assertFalse(rsa1 != rsa2) self.assertTrue(rsa1 != rsa3) self.assertTrue(rsa1 != dsa) - self.assertTrue(rsa1 != object) - self.assertTrue(rsa1 != None) + self.assertTrue(rsa1 is not object) + self.assertTrue(rsa1 is None) def test_type(self): """ @@ -813,47 +809,6 @@ def test_generate_failed(self): 'Key size must be 1024, 2048, 3072, or 4096 bits.', context.exception.message) - def test_guessStringType(self): - """ - Test that the _guessStringType method guesses string types - correctly. - - Imported from Twisted. - """ - self.assertEqual( - keys.Key._guessStringType(keydata.publicRSA_openssh.encode('ascii')), - 'public_openssh') - self.assertEqual( - keys.Key._guessStringType(keydata.publicDSA_openssh.encode('ascii')), - 'public_openssh') - self.assertEqual( - keys.Key._guessStringType( - keydata.privateRSA_openssh.encode('ascii')), - 'private_openssh') - self.assertEqual( - keys.Key._guessStringType( - keydata.privateDSA_openssh.encode('ascii')), - 'private_openssh') - self.assertEqual( - keys.Key._guessStringType( - keydata.privateRSA_agentv3.encode('ascii')), - 'agentv3') - self.assertEqual( - keys.Key._guessStringType( - keydata.privateDSA_agentv3.encode('ascii')), - 'agentv3') - self.assertEqual( - keys.Key._guessStringType( - b'\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x01\x01'), - 'blob') - self.assertEqual( - keys.Key._guessStringType( - b'\x00\x00\x00\x07ssh-dss\x00\x00\x00\x01\x01'), - 'blob') - self.assertEqual( - keys.Key._guessStringType(b'not a key'), - None) - def test_guessStringType_unknown(self): """ None is returned when could not detect key type. @@ -936,15 +891,6 @@ def test_guessStringType_private_OpenSSH_DSA(self): self.assertEqual('private_openssh', result) - def test_guessStringType_private_OpenSSH_ECDSA(self): - """ - Can recognize an OpenSSH ECDSA private key. - """ - result = Key._guessStringType( - keydata.privateECDSA_256_openssh.encode('ascii')) - - self.assertEqual('private_openssh', result) - def test_guessStringType_public_OpenSSH(self): """ Can recognize an OpenSSH public key. @@ -961,25 +907,6 @@ def test_guessStringType_public_PKCS1(self): self.assertEqual('public_pkcs1_rsa', result) - def test_guessStringType_public_OpenSSH_ECDSA(self): - """ - Can recognize an OpenSSH public key. - """ - result = Key._guessStringType( - keydata.publicECDSA_256_openssh.encode('ascii')) - - self.assertEqual('public_openssh', result) - - result = Key._guessStringType( - keydata.publicECDSA_384_openssh.encode('ascii')) - - self.assertEqual('public_openssh', result) - - result = Key._guessStringType( - keydata.publicECDSA_521_openssh.encode('ascii')) - - self.assertEqual('public_openssh', result) - def test_guessStringType_private_SSHCOM(self): """ Can recognize an SSH.com private key. @@ -1250,27 +1177,6 @@ def test_fromString_BLOB_blob_type_non_ascii(self): '"b\'\\x00\\x00\\x00\\nssh-\\xc2\\xbd\\xc2\\xbd\\xc2\\xbd\'"' ) - def test_fromString_PRIVATE_BLOB(self): - """ - Test that a private key is correctly generated from a private key blob. - """ - rsaBlob = (common.NS('ssh-rsa') + common.MP(2) + common.MP(3) + - common.MP(4) + common.MP(5) + common.MP(6) + common.MP(7)) - rsaKey = keys.Key._fromString_PRIVATE_BLOB(rsaBlob) - dsaBlob = (common.NS('ssh-dss') + common.MP(2) + common.MP(3) + - common.MP(4) + common.MP(5) + common.MP(6)) - dsaKey = keys.Key._fromString_PRIVATE_BLOB(dsaBlob) - badBlob = common.NS('ssh-bad') - self.assertFalse(rsaKey.isPublic()) - self.assertEqual( - rsaKey.data(), - {'n': 2, 'e': 3, 'd': 4, 'u': 5, 'p': 6, 'q': 7}) - self.assertFalse(dsaKey.isPublic()) - self.assertEqual( - dsaKey.data(), {'p': 2, 'q': 3, 'g': 4, 'y': 5, 'x': 6}) - self.assertRaises( - keys.BadKeyError, keys.Key._fromString_PRIVATE_BLOB, badBlob) - def test_blobRSA(self): """ Return the over-the-wire SSH format of the RSA public key. @@ -1308,11 +1214,14 @@ def test_blobEC(self): keys.Key(self.ecObj).blob(), common.NS(keydata.ECDatanistp256['curve']) + common.NS(keydata.ECDatanistp256['curve'][-8:]) + - common.NS(b'\x04' + - utils.int_to_bytes( - self.ecObj.private_numbers().public_numbers.x, byteLength) + - utils.int_to_bytes( - self.ecObj.private_numbers().public_numbers.y, byteLength)) + common.NS( + b'\x04' + + utils.int_to_bytes( + self.ecObj.private_numbers().public_numbers.x, byteLength + ) + + utils.int_to_bytes( + self.ecObj.private_numbers().public_numbers.y, byteLength) + ) ) def test_blobEd25519(self): @@ -1324,13 +1233,13 @@ def test_blobEd25519(self): publicBytes = self.ed25519Obj.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw - ) + ) self.assertEqual( keys.Key(self.ed25519Obj).blob(), common.NS(b'ssh-ed25519') + common.NS(publicBytes) - ) + ) def test_blobNoKey(self): """ @@ -1401,19 +1310,19 @@ def test_privateBlobEd25519(self): publicBytes = self.ed25519Obj.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw - ) + ) privateBytes = self.ed25519Obj.private_bytes( serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption() - ) + ) self.assertEqual( keys.Key(self.ed25519Obj).privateBlob(), common.NS(b'ssh-ed25519') + common.NS(publicBytes) + common.NS(privateBytes + publicBytes) - ) + ) def test_privateBlobNoKeyObject(self): """ @@ -1459,28 +1368,13 @@ def test_fromString_PUBLIC_OPENSSH_DSA(self): self.checkParsedDSAPublic1024(sut) - def test_fromString_OpenSSH(self): + def test_fromString_OpenSSH_public(self): """ - Test that keys are correctly generated from OpenSSH strings. + It can load an OpenSSH public key. """ - self._testPublicPrivateFromString( - keydata.publicRSA_openssh, - keydata.privateRSA_openssh, 'RSA', keydata.RSAData) - - self.assertEqual( - keys.Key.fromString( - keydata.privateRSA_openssh_encrypted, - passphrase=b'encrypted'), - keys.Key.fromString(keydata.privateRSA_openssh)) - - self.assertEqual( - keys.Key.fromString( - keydata.privateRSA_openssh_alternate), - keys.Key.fromString(keydata.privateRSA_openssh)) + sut = Key.fromString(OPENSSH_RSA_PUBLIC) - self._testPublicPrivateFromString( - keydata.publicDSA_openssh, - keydata.privateDSA_openssh, 'DSA', keydata.DSAData) + self.checkParsedRSAPublic1024(sut) def test_fromString_OpenSSH_private_missing_password(self): """ @@ -1528,44 +1422,34 @@ def test_fromString_PRIVATE_OPENSSH_newer(self): passphrase=b'testxp') self.assertEqual(key, key2) - def test_fromString_PRIVATE_OPENSSH_not_encrypted_with_passphrase(self): + def test_toString_OPENSSH_rsa(self): """ - When loading a unencrypted OpenSSH private key with passhphrase - will raise BadKeyError. + Test that the Key object generates OpenSSH keys correctly. """ + key = Key.fromString(OPENSSH_V1_RSA_PRIVATE) - with self.assertRaises(BadKeyError) as context: - Key.fromString(OPENSSH_RSA_PRIVATE, passphrase='pass') + result = key.public().toString('openssh') + self.assertEqual(OPENSSH_RSA_PUBLIC, result) - self.assertEqual( - 'OpenSSH key not encrypted', - context.exception.message) + result = key.toString('openssh') + self.assertEqual(OPENSSH_RSA_PRIVATE, result) - def test_toString_OPENSSH(self): + def test_toString_OPENSSH_v1_rsa(self): """ Test that the Key object generates OpenSSH keys correctly. """ - key = keys.Key.fromString(keydata.privateRSA_lsh) + key = Key.fromString(OPENSSH_RSA_PRIVATE) - self.assertEqual(key.toString('openssh'), keydata.privateRSA_openssh) - self.assertEqual( - key.toString('openssh', 'encrypted'), - keydata.privateRSA_openssh_encrypted) - self.assertEqual( - key.public().toString('openssh'), - keydata.publicRSA_openssh[:-8]) - self.assertEqual( - key.public().toString('openssh', 'comment'), - keydata.publicRSA_openssh) + result = key.public().toString('openssh_v1') + self.assertEqual(OPENSSH_RSA_PUBLIC, result) - key = keys.Key.fromString(keydata.privateDSA_lsh) - - self.assertEqual(key.toString('openssh'), keydata.privateDSA_openssh) - self.assertEqual( - key.public().toString('openssh', 'comment'), - keydata.publicDSA_openssh) - self.assertEqual( - key.public().toString('openssh'), keydata.publicDSA_openssh[:-8]) + result = key.toString('openssh_v1') + self.assertStartsWith( + b'-----BEGIN OPENSSH PRIVATE KEY-----\n' + b'b3BlbnNzaC1rZXk', + result) + reloaded = Key.fromString(result) + self.assertEqual(reloaded, key) def addSSHCOMKeyHeaders(self, source, headers): """ @@ -1577,8 +1461,8 @@ def addSSHCOMKeyHeaders(self, source, headers): for key, value in headers.items(): line = '{}: {}'.format(key, value) header = '\\\n'.join(textwrap.wrap(line, 70)) - lines.insert(1, header) - return '\n'.join(lines) + lines.insert(1, header.encode('utf-8')) + return b'\n'.join(lines) def checkParsedDSAPublic1024(self, sut): """ @@ -1663,7 +1547,11 @@ def checkParsedRSAPrivate1024(self, sut): """ self.assertEqual(1024, sut.size()) self.assertEqual('RSA', sut.type()) - self.assertFalse(sut.isPublic()) + self.assertEqual(b'ssh-rsa', sut.sshType()) + self.assertEqual( + 'fc:39:4c:d4:51:c8:5d:78:1e:4d:9d:1e:73:42:52:55', + sut.fingerprint()) + self.assertIsFalse(sut.isPublic()) data = sut.data() self.assertEqual(65537, data['e']) self.checkParsedRSAPublic1024Data(sut) @@ -1815,15 +1703,6 @@ def test_fromString_PRIVATE_OPENSSH_v1_DSA(self): self.checkParsedDSAPrivate1024(sut) - def test_fromString_PRIVATE_OPENSSH_ECDSA(self): - """ - Can not load a private OPENSSH ECDSA. - """ - self.assertBadKey( - keydata.privateECDSA_256_openssh, - 'Key type \'EC\' not supported.' - ) - def test_fromString_PRIVATE_OPENSSH_short(self): """ Raise an error when private OpenSSH key is too short. @@ -2708,14 +2587,14 @@ def test_fingerprintBadFormat(self): 'Unsupported fingerprint format: sha256-base', em.exception.args[0]) - def test_sign(self): + def test_sign_rsa(self): """ Test that the Key object generates correct signatures. """ key = keys.Key.fromString(keydata.privateRSA_openssh) - self.assertEqual(key.sign(''), self.rsaSignature) - key = keys.Key.fromString(keydata.privateDSA_openssh) - self.assertEqual(key.sign(''), self.dsaSignature) + signature = key.sign(b'') + self.assertTrue(key.verify(signature, b'')) + self.assertEqual(signature, self.rsaSignature) def test_verify(self): """ @@ -2750,6 +2629,97 @@ def test_repr(self): result ) + def test_fromString_PRIVATE_PUTTY_V3_short(self): + """ + An exception is raised when key is too short. + """ + content = 'PuTTY-User-Key-File-3: ssh-rsa' + + self.assertKeyIsTooShort(content) + + content = ( + 'PuTTY-User-Key-File-3: ssh-rsa\n' + 'Encryption: aes256-cbc\n' + ) + + self.assertKeyIsTooShort(content) + + content = ( + 'PuTTY-User-Key-File-3: ssh-rsa\n' + 'Encryption: aes256-cbc\n' + 'Comment: bla\n' + ) + + self.assertKeyIsTooShort(content) + + def test_fromString_PRIVATE_PUTTY_V3_RSA_bad_password(self): + """ + An exception is raised when password is not valid. + """ + with self.assertRaises(EncryptedKeyError) as context: + Key.fromString( + PUTTY_V3_RSA_PRIVATE_WITH_PASSWORD, passphrase=b'bad-pass') + + self.assertEqual( + 'Bad password or HMAC mismatch.', context.exception.message) + + def test_fromString_PRIVATE_PUTTY_V3_RSA_missing_password(self): + """ + An exception is raised when key is encrypted but no password was + provided. + """ + with self.assertRaises(EncryptedKeyError) as context: + Key.fromString(PUTTY_V3_RSA_PRIVATE_WITH_PASSWORD) + + self.assertEqual( + 'Passphrase must be provided for an encrypted key.', + context.exception.message) + + def test_fromString_PRIVATE_PUTTY_V3_unsupported_type(self): + """ + An exception is raised when key contain a type which is not supported. + """ + content = """PuTTY-User-Key-File-3: ssh-bad +IGNORED +""" + self.assertBadKey( + content, 'Unsupported key type: "ssh-bad"') + + def test_fromString_PRIVATE_PUTTY_V3_unsupported_encryption(self): + """ + An exception is raised when key contain an encryption method + which is not supported. + """ + content = """PuTTY-User-Key-File-3: ssh-dss +Encryption: aes126-cbc +IGNORED +""" + self.assertBadKey( + content, 'Unsupported encryption type: "aes126-cbc"') + + def test_fromString_PRIVATE_PUTTY_v3_type_mismatch(self): + """ + An exception is raised when key header advertise one key type while + the public key another. + """ + content = """PuTTY-User-Key-File-3: ssh-rsa +Encryption: aes256-cbc +Comment: imported-openssh-key +Public-Lines: 4 +AAAAB3NzaC1kc3MAAAADAQABAAAAgQC4fV6tSakDSB6ZovygLsf1iC9P3tJHePTK +APkPAWzlu5BRHcmAu0uTjn7GhrpxbjjWMwDVN0Oxzw7teI0OEIVkpnlcyM6L5mGk ++X6Lc4+lAfp1YxCR9o9+FXMWSJP32jRwI+4LhWYxnYUldvAO5LDz9QeR0yKimwcj +RToF6/jpLw== +IGNORED +""" + self.assertBadKey( + content, + ( + 'Mismatch key type. Header has "ssh-rsa",' + ' public has "ssh-dss"'), + ) + + class Test_generate_ssh_key_parser(ChevahTestCase, CommandLineMixin): """ Unit tests for generate_ssh_key_parser. @@ -2857,7 +2827,7 @@ def test_generate_ssh_key_custom_values(self): file_name_pub = file_name + '.pub' options = self.parseArguments([ self.sub_command_name, - u'--key-size=512', + u'--key-size=2048', u'--key-type=DSA', u'--key-file=' + file_name, u'--key-comment=this is a comment', @@ -2867,36 +2837,39 @@ def test_generate_ssh_key_custom_values(self): exit_code, message, key = generate_ssh_key( options, open_method=open_method) + self.assertEqual( + 'SSH key of type "ssh-dss" and length "2048" generated as public ' + 'key file "%s" and private key file "%s" ' + 'without comment as not supported by the output format.' % ( + file_name_pub, file_name), + message, + ) + self.assertEqual(0, exit_code) + self.assertEqual('DSA', key.type()) - self.assertEqual(512, key.size()) + self.assertEqual(2048, key.size()) # First it writes the private key. first_file = open_method.calls.pop(0) - self.assertPathEqual( - _path(file_name), first_file['path']) + self.assertPathEqual(file_name, first_file['path']) self.assertEqual('wb', first_file['mode']) - self.assertEqual( - key.toString('openssh'), first_file['stream'].getvalue()) + # OpenSSH V1 format has a random value generated when storing + # the private key. + self.assertStartsWith( + b'-----BEGIN OPENSSH PRIVATE KEY-----\n' + b'b3BlbnNzaC1r', + first_file['stream'].getvalue()) # Second it writes the public key. second_file = open_method.calls.pop(0) self.assertPathEqual( - _path(file_name_pub.decode('ascii')), second_file['path']) + file_name_pub, second_file['path']) self.assertEqual('wb', second_file['mode']) self.assertEqual( - key.public().toString('openssh', 'this is a comment'), + key.public().toString('openssh_v1'), second_file['stream'].getvalue()) - self.assertEqual( - u'SSH key of type "dsa" and length "512" generated as public ' - u'key file "%s" and private key file "%s" ' - u'having comment "this is a comment".' % ( - file_name_pub, file_name), - message, - ) - self.assertEqual(0, exit_code) - def test_generate_ssh_key_default_values(self): """ When no path and no comment are provided, it will use default @@ -2939,7 +2912,6 @@ def test_generate_ssh_key_default_values(self): self.assertEqual( key.public().toString('openssh'), second_file['stream'].getvalue()) - def test_generate_ssh_key_private_exist_no_migration(self): """ When no migration is done it will not generate the key, diff --git a/src/chevah_keycert/tests/test_ssl.py b/src/chevah_keycert/tests/test_ssl.py index 18ee718..b2bad95 100644 --- a/src/chevah_keycert/tests/test_ssl.py +++ b/src/chevah_keycert/tests/test_ssl.py @@ -169,7 +169,8 @@ def test_common_name_required(self): def test_default(self): """ - It can be initialized with only a subparserfile has no and sub-command name. + It can be initialized with only a subparserfile has no and sub-command + name. """ generate_csr_parser(self.subparser, 'key-gen')