Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add encrypt_type9 function to netutils.password #216

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
93 changes: 93 additions & 0 deletions netutils/password.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import ast
import typing as t
from functools import wraps
import hashlib

# 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 +143,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_type7("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 +264,68 @@ def encrypt_type7(unencrypted_password: str, salt: t.Optional[int] = None) -> st
return encrypted_password


def _wpa_base64_encode(data: bytes):
alextremblay marked this conversation as resolved.
Show resolved Hide resolved
# Adapted with many thanks from https://github.com/skrobul/cisco_scrypt/blob/main/lib/cisco_scrypt.rb

# Cisco uses non-standard base64 encoding, which happens to be the same
# implementation as used for WPA passwords.
# this involves encoding 3 bytes at a time.

# First we need to pad the string with NULs until its length is a multiple of 3
modulus = len(data) % 3
padding = b"\x00" * (3 - modulus)
data += padding
result = ""

# Now we can encode the string in 3-byte chunks
for chunk in [data[i:i+3] for i in range(0, len(data), 3)]:
# Now we run each chunk through the WPA base64 encoding algorythm
# The fiddly bit is that the iteration count depends on the length of the chunk, excluding the padding, +1
# so if the chunk is 3 bytes long, we iterate 4 times, if it's 2 bytes long, we iterate 3 times, etc.
byte_count = len(chunk.strip(b"\x00"))
iterations = byte_count + 1
value = int.from_bytes(chunk, byteorder="big")
encoded_chunk = ""
for _ in range(iterations):
position = (value & 0xFC0000) >> 18
encoded_chunk += ENCRYPT_TYPE9_ENCODING_CHARS[position]
value = value << 6
result += encoded_chunk

return result


def encrypt_type9(unencrypted_password: str, salt: t.Optional[str] = None) -> str:
"""Given an unencrypted password of Cisco Type 9 password, encypt it.

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_type7("123456")
"$9$cvWdfQlRRDKq/U$VFTPha5VHTCbSgSUAo.nPoh50ZiXOw1zmljEjXkaq1g"
>>> encrypt_type7("123456", "cvWdfQlRRDKq/U")
"$9$cvWdfQlRRDKq/U$VFTPha5VHTCbSgSUAo.nPoh50ZiXOw1zmljEjXkaq1g"
"""
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 = hashlib.scrypt(unencrypted_password.encode(), salt=salt_bytes, n=2**14, r=1, p=1, dklen=32)
hash = _wpa_base64_encode(key)

return f"$9${salt_bytes.decode()}${hash}"


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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ invoke = "*"
flake8 = "*"
pylint = "*"
pytest = "*"
pytest-cov = "*"
pyyaml = "*"
pydocstyle = "*"
sphinx-rtd-theme = "*"
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"]