From d8670fc558665ee5fa6e028fa6478842becd5327 Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Mon, 5 Dec 2022 15:58:33 +0100 Subject: [PATCH] fix(ldap): replace SHA512 user passwords with SSHA --- README.md | 2 +- lumi2/ldap.py | 8 ++-- lumi2/usermodel.py | 57 +++++++++++++++++++++++++---- tests/conftest.py | 2 +- tests/ldap_mock_server_entries.json | 8 ++-- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e314f03..704009f 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ If you point Lumi2 at an existing LDAP server, make sure its DIT matches the str - `displayName` - preferred name (or nickname) - `mail` - email address - `jpegPhoto` - profile picture in JPEG format - - `userPassword` - SHA512 password hash + - `userPassword` - SSHA password hash The `uid` (username) can contain latin characters, digits, underscores, hypens and periods, and must have a letter as the first character. diff --git a/lumi2/ldap.py b/lumi2/ldap.py index b0240db..1528750 100644 --- a/lumi2/ldap.py +++ b/lumi2/ldap.py @@ -623,9 +623,9 @@ def get_user(connection: Connection, uid: str) -> User: last_name = attributes['sn'][0] display_name = attributes['displayName'][0] - # Retrieve base64-encoded password hash prefixed with '{SHA512}' + # Retrieve base64-encoded password hash prefixed with '{SSHA}' password_hash = attributes['userPassword'][0] - expected_hash_type = '{SHA512}' + expected_hash_type = '{SSHA}' if not password_hash.startswith(expected_hash_type): raise InvalidAttributeException( f"Unexpected password hash in entry '{user_dn}': expected " \ @@ -676,7 +676,7 @@ def create_user(connection: Connection, user: User) -> None: attributes = { "uid": user.username, - "userPassword": "{SHA512}" + user.password_hash, + "userPassword": "{SSHA}" + user.password_hash, "cn": user.first_name, "sn": user.last_name, "displayName": user.display_name, @@ -723,7 +723,7 @@ def update_user(connection: Connection, user: User) -> None: user.picture.save(new_picture_bytes, format="jpeg") new_attributes = { - "userPassword": [(MODIFY_REPLACE, ["{SHA512}" + user.password_hash])], + "userPassword": [(MODIFY_REPLACE, ["{SSHA}" + user.password_hash])], "mail": [(MODIFY_REPLACE, [user.email])], "cn": [(MODIFY_REPLACE, [user.first_name])], "sn": [(MODIFY_REPLACE, [user.last_name])], diff --git a/lumi2/usermodel.py b/lumi2/usermodel.py index 8fb4dcc..f7d4e04 100644 --- a/lumi2/usermodel.py +++ b/lumi2/usermodel.py @@ -2,9 +2,10 @@ from string import ascii_lowercase, ascii_uppercase, digits, whitespace from base64 import b64encode, b64decode -import hashlib +from hashlib import sha1 from binascii import Error as Base64DecodeError from pathlib import Path +from os import urandom from PIL import Image from PIL.JpegImagePlugin import JpegImageFile @@ -21,7 +22,7 @@ class User: username : str The user's username. password_hash : str - Base64-encoded SHA512 hash of the user's password. + Base64-encoded SSHA hash of the user's password. email : str The user's email address. first_name : str @@ -268,7 +269,9 @@ class User: @staticmethod def generate_password_hash(password: str) -> str: - """Generates a base64-encoded SHA512 hash of the input string. + """Generates a base64-encoded SSHA hash of the input string. + + The 4-byte salt is appended to the digest prior to base64-encoding. Parameters ---------- @@ -278,7 +281,7 @@ class User: Returns ------- str - A base64-encoded SHA512 hash digest of the input string. + A base64-encoded SSHA hash digest of the input string. Raises ------ @@ -293,9 +296,11 @@ class User: if not len(password): raise ValueError("Input string cannot be empty.") - hash_bytes = hashlib.sha512() + salt = urandom(4) + hash_bytes = sha1() hash_bytes.update(bytes(password, "UTF-8")) - return b64encode(hash_bytes.digest()).decode("ASCII") + hash_bytes.update(salt) + return b64encode(hash_bytes.digest() + salt).decode("ASCII") @staticmethod @@ -325,8 +330,8 @@ class User: username : str The username, valid as described by User.assert_is_valid_username(). password_hash : str - The user's base64-encoded SHA512-hashed password (without the - '{SHA512}'-prefix expected by LDAP). + The user's base64-encoded SSHA-hashed password (without the + '{SSHA}'-prefix expected by LDAP). Must be valid as described by User.assert_is_valid_password_hash(). email : str The User's email address. @@ -438,6 +443,42 @@ class User: return groups + def check_password(self, password_plaintext: str) -> bool: + """Checks the input plaintext password against this User's password hash. + + Parameters + ---------- + password_plaintext : str + The plaintext password to check against this User's salted password + hash. + + Returns + ------- + True : bool + If the input password_plaintext is this User's password. + False : bool + Otherwise. + + Raises + ------ + TypeError + If password_plaintext is not of type string. + """ + + if not isinstance(password_plaintext, str): + raise TypeError(f"Expected a string but got: '{type(password_plaintext)}'.") + + password_hash_bytes = b64decode(self.password_hash) + digest_bytes = password_hash_bytes[:20] + salt = password_hash_bytes[20:] + + validation_hash = sha1() + validation_hash.update(bytes(password_plaintext, "UTF-8")) + validation_hash.update(salt) + + return validation_hash.digest() == digest_bytes + + def __eq__(self, other): return self.username == other.username diff --git a/tests/conftest.py b/tests/conftest.py index 9925c7b..0ccd9c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,7 +98,7 @@ def connection(): - displayName (nickname) - mail (email) - jpegPhoto (profile picture) - - password (sha512 hash of password 'test') + - password (SSHA hash of password 'test') Both groups are of type 'groupOfUniqueNames'. Alice is a member of both groups. Bob is a member of 'employees'. diff --git a/tests/ldap_mock_server_entries.json b/tests/ldap_mock_server_entries.json index 8fadc84..3f2e5d6 100644 --- a/tests/ldap_mock_server_entries.json +++ b/tests/ldap_mock_server_entries.json @@ -95,7 +95,7 @@ "alice" ], "userPassword": [ - "{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w==" + "{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K" ] }, "dn": "uid=alice,ou=users,dc=example,dc=com", @@ -126,7 +126,7 @@ "alice" ], "userPassword": [ - "{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w==" + "{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K" ] } }, @@ -156,7 +156,7 @@ "bobbuilder" ], "userPassword": [ - "{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w==" + "{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K" ] }, "dn": "uid=bobbuilder,ou=users,dc=example,dc=com", @@ -187,7 +187,7 @@ "bobbuilder" ], "userPassword": [ - "{SHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w==" + "{SSHA}LitOJVWDVqqglNuHS0o1ypQjyd4TUP4K" ] } },