Skip to content

Commit

Permalink
Add encrypt_type9 function to netutils.password (#253)
Browse files Browse the repository at this point in the history
* add missing dev dependency 'pytest-cov'

pytest option '--no-cov' causes pytest to crash if pytest-cov plugin not installed

add 'cryptography' to optional dependencies

cisco type 9 passwords use the scrypt key derivation function, as implemented in the cryptography package

implement `encrypt_type9` function

add test for `encrypt_type9` function

add `encrypt_type9` to jinja filters mapping

run `development_scripts.py` to update docs

improve encrypt_type9 salt generator

salt generator using base85 may produce salt which includes '$'.

The `get_hash_salt` function will not be able to reliably extract the salt from any hash produced using this generator

switches to a `secrets`-based salt generator

implement `compare_type9` function

add tests for `compare_type9` function

update jinja filter mapping and docs

replace scrypt KDF implementation

remove dependence on `cryptography` package

simplify salt generator

update test data

update test suite with input/output derived from real switch config

add cryptography as an optional dependency

make scrypt implementation compatible with more python versions

clean up base64 encoding scheme in `encrypt_type9`

lint `password.py`

* Removed dependency on external library

* Reverted pyproject.toml

* Removed optional code

---------

Co-authored-by: Alex Tremblay <[email protected]>
Co-authored-by: Andrew Bates <[email protected]>
  • Loading branch information
3 people authored Apr 13, 2023
1 parent 2db98a3 commit 5931441
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 0 deletions.
2 changes: 2 additions & 0 deletions docs/user/include_jinja_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
92 changes: 92 additions & 0 deletions netutils/password.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions netutils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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."},
Expand All @@ -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"]
Expand All @@ -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"]

0 comments on commit 5931441

Please sign in to comment.