diff --git a/docs/user/include_jinja_list.md b/docs/user/include_jinja_list.md index 219e04db..731e8b1f 100644 --- a/docs/user/include_jinja_list.md +++ b/docs/user/include_jinja_list.md @@ -53,9 +53,11 @@ | mac_type | netutils.mac.mac_type | | compare_type5 | netutils.password.compare_type5 | | compare_type7 | netutils.password.compare_type7 | +| compare_type9 | netutils.password.compare_type9 | | decrypt_type7 | netutils.password.decrypt_type7 | | encrypt_type5 | netutils.password.encrypt_type5 | | encrypt_type7 | netutils.password.encrypt_type7 | +| encrypt_type9 | netutils.password.encrypt_type9 | | get_hash_salt | netutils.password.get_hash_salt | | tcp_ping | netutils.ping.tcp_ping | | longest_prefix_match | netutils.route.longest_prefix_match | diff --git a/netutils/password.py b/netutils/password.py index 282b215f..d317d31a 100644 --- a/netutils/password.py +++ b/netutils/password.py @@ -8,12 +8,22 @@ import ast import typing as t from functools import wraps +import base64 + +try: + from hashlib import scrypt + + HAS_SCRYPT = True +except ImportError: + HAS_SCRYPT = False + # Code example from Python docs ALPHABET = string.ascii_letters + string.digits DEFAULT_PASSWORD_CHARS = "".join((string.ascii_letters + string.digits + ".,:-_")) DEFAULT_PASSWORD_LENGTH = 20 ENCRYPT_TYPE7_LENGTH = 25 +ENCRYPT_TYPE9_ENCODING_CHARS = "".join(("./", string.digits, string.ascii_uppercase, string.ascii_lowercase)) XLAT = [ "0x64", @@ -141,6 +151,35 @@ def compare_type7( return False +def compare_type9( + unencrypted_password: str, encrypted_password: str, return_original: bool = False +) -> t.Union[str, bool]: + """Given an encrypted and unencrypted password of Cisco Type 7 password, compare if they are a match. + + Args: + unencrypted_password: A password that has not been encrypted, and will be compared against. + encrypted_password: A password that has been encrypted. + return_original: Whether or not to return the original, this is helpful when used to populate the configuration. Defaults to False. + + Returns: + Whether or not the password is as compared to. + + Examples: + >>> from netutils.password import compare_type9 + >>> compare_type9("cisco","$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2") + True + >>> compare_type9("not_cisco","$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2") + False + >>> + """ + salt = get_hash_salt(encrypted_password) + if encrypt_type9(unencrypted_password, salt) == encrypted_password: + if return_original is True: + return encrypted_password + return True + return False + + def decrypt_type7(encrypted_password: str) -> str: """Given an unencrypted password of Cisco Type 7 password decrypt it. @@ -233,6 +272,59 @@ def encrypt_type7(unencrypted_password: str, salt: t.Optional[int] = None) -> st return encrypted_password +def encrypt_type9(unencrypted_password: str, salt: t.Optional[str] = None) -> str: + """Given an unencrypted password of Cisco Type 9 password, encrypt it. + + Note: This uses the built-in Python `scrypt` function to generate the password + hash. However, this function is not available on the default Python installed + on MacOS. If MacOS is used, it is recommended to install Python using Homebrew + (or similar) which will include `scrypt`. + + Args: + unencrypted_password: A password that has not been encrypted, and will be compared against. + salt: a 14-character string that can be set by the operator. Defaults to random generated one. + + Returns: + The encrypted password. + + Examples: + >>> from netutils.password import encrypt_type9 + >>> encrypt_type9("123456", "cvWdfQlRRDKq/U") + '$9$cvWdfQlRRDKq/U$VFTPha5VHTCbSgSUAo.nPoh50ZiXOw1zmljEjXkaq1g' + + Raises: + ImportError: If `scrypt` cannot be imported from the system. + """ + if not HAS_SCRYPT: + raise ImportError( + "Your version of python does not have scrypt support built in. " + "Please install a version of python with scrypt." + ) + + if salt: + if len(salt) != 14: + raise ValueError("Salt must be 14 characters long.") + salt_bytes = salt.encode() + else: + # salt must always be a 14-byte-long printable string, often includes symbols + salt_bytes = "".join(secrets.choice(ENCRYPT_TYPE9_ENCODING_CHARS) for _ in range(14)).encode() + + key = scrypt(unencrypted_password.encode(), salt=salt_bytes, n=2**14, r=1, p=1, dklen=32) + + # Cisco type 9 uses a different base64 encoding than the standard one, so we need to translate from + # the standard one to the Cisco one. + type9_encoding_translation_table = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + ENCRYPT_TYPE9_ENCODING_CHARS, + ) + hashed_password = base64.b64encode(key).decode().translate(type9_encoding_translation_table) + + # and strip off the trailing '=' + hashed_password = hashed_password[:-1] + + return f"$9${salt_bytes.decode()}${hashed_password}" + + def get_hash_salt(encrypted_password: str) -> str: """Given an encrypted password obtain the salt value from it. diff --git a/netutils/utils.py b/netutils/utils.py index ff52ac7a..710845f2 100644 --- a/netutils/utils.py +++ b/netutils/utils.py @@ -54,9 +54,11 @@ "get_oui": "mac.get_oui", "compare_type5": "password.compare_type5", "compare_type7": "password.compare_type7", + "compare_type9": "password.compare_type9", "decrypt_type7": "password.decrypt_type7", "encrypt_type5": "password.encrypt_type5", "encrypt_type7": "password.encrypt_type7", + "encrypt_type9": "password.encrypt_type9", "get_hash_salt": "password.get_hash_salt", "tcp_ping": "ping.tcp_ping", "longest_prefix_match": "route.longest_prefix_match", diff --git a/tests/unit/test_password.py b/tests/unit/test_password.py index 6ed43750..2105e9cf 100644 --- a/tests/unit/test_password.py +++ b/tests/unit/test_password.py @@ -41,6 +41,31 @@ }, ] +COMPARE_TYPE9 = [ + { + "sent": { + "unencrypted_password": "cisco", + "encrypted_password": "$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2", + }, + "received": True, + }, + { + "sent": { + "unencrypted_password": "cisco", + "encrypted_password": "$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2", + "return_original": True, + }, + "received": "$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2", + }, + { + "sent": { + "unencrypted_password": "invalid_password", + "encrypted_password": "$9$588|P!iWqEx=Wf$nadLmT9snc6V9QAeUuATSOoCAZMQIHqixJfZpQj5EU2", + }, + "received": False, + }, +] + DECRYPT_TYPE7 = [ { "sent": {"encrypted_password": "14141B180F0B"}, @@ -62,6 +87,13 @@ }, ] +ENCRYPT_TYPE9 = [ + { + "sent": {"unencrypted_password": "cisco", "salt": "x2xAAwQ3MBbEnk"}, + "received": "$9$x2xAAwQ3MBbEnk$JCxr6MnPb.k5ymK72mTypyRJYH5W74ZRvtLTprCj.xQ", + }, +] + GET_HASH_SALT = [ { "sent": {"encrypted_password": "$1$nTc1$Z28sUTcWfXlvVe2x.3XAa."}, @@ -80,6 +112,11 @@ def test_compare_type7(data): assert password.compare_type7(**data["sent"]) == data["received"] +@pytest.mark.parametrize("data", COMPARE_TYPE9) +def test_compare_type9(data): + assert password.compare_type9(**data["sent"]) == data["received"] + + @pytest.mark.parametrize("data", DECRYPT_TYPE7) def test_decrypt_type7(data): assert password.decrypt_type7(**data["sent"]) == data["received"] @@ -95,6 +132,11 @@ def test_encrypt_type7(data): assert password.encrypt_type7(**data["sent"]) == data["received"] +@pytest.mark.parametrize("data", ENCRYPT_TYPE9) +def test_encrypt_type9(data): + assert password.encrypt_type9(**data["sent"]) == data["received"] + + @pytest.mark.parametrize("data", GET_HASH_SALT) def test_get_hash_salt(data): assert password.get_hash_salt(**data["sent"]) == data["received"]