diff --git a/keystone/common/password_hashers/bcrypt.py b/keystone/common/password_hashers/bcrypt.py index f7c8709e60..037538d8d8 100644 --- a/keystone/common/password_hashers/bcrypt.py +++ b/keystone/common/password_hashers/bcrypt.py @@ -15,10 +15,11 @@ import hmac import bcrypt +from keystone.common import password_hashers from keystone import exception -class Bcrypt: +class Bcrypt(password_hashers.PasswordHasher): """passlib transition class for implementing bcrypt password hashing""" name: str = "bcrypt" @@ -57,7 +58,7 @@ class Bcrypt: return bcrypt.checkpw(password, hashed.encode("ascii")) -class Bcrypt_sha256: +class Bcrypt_sha256(password_hashers.PasswordHasher): """passlib transition class for bcrypt_sha256 password hashing""" name: str = "bcrypt_sha256" diff --git a/keystone/common/password_hashers/pbkdf2.py b/keystone/common/password_hashers/pbkdf2.py new file mode 100644 index 0000000000..98794ce1b3 --- /dev/null +++ b/keystone/common/password_hashers/pbkdf2.py @@ -0,0 +1,116 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import binascii +import os + +from cryptography.exceptions import InvalidKey +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from keystone.common import password_hashers +from keystone import exception + + +class Sha512(password_hashers.PasswordHasher): + """passlib transition class for PBKDF2 Sha512 password hashing""" + + name: str = "pbkdf2_sha512" + ident: str = "$pbkdf2-sha512$" + hash_algo = hashes.SHA512() + + @staticmethod + def hash( + password: bytes, + salt_size: int = 16, + rounds: int = 25000, + ) -> str: + """Generate password hash string with ident and params + + https://cryptography.io/en/stable/hazmat/primitives/key-derivation-functions/#pbkdf2 + + :param bytes password: Password to be hashed. + :param bytes salt: Salt. + :param int iterations: Iterations count + + :returns: String in format + `$pbkdf2-sha512$ln=logN,r=R,p=P$salt$checksum` + """ + salt: bytes = os.urandom(salt_size) + + # Prepave the kdf function with params + kdf = PBKDF2HMAC( + algorithm=Sha512.hash_algo, + length=64, + salt=salt, + iterations=rounds, + ) + + # derive - create a digest + key: bytes = kdf.derive(password) + + # make a `str` digest compatible with passlib + digest_str: str = ( + binascii.b2a_base64(key).rstrip(b"=\n").decode("ascii") + ) + # make a `str` salt + salt_str: str = ( + binascii.b2a_base64(salt).rstrip(b"=\n").decode("ascii") + ) + + return f"$pbkdf2-sha512${rounds}${salt_str}${digest_str}" + + @staticmethod + def verify(password: bytes, hashed: str) -> bool: + """Verify hashing password would be equal to the `hashed` value + + :param bytes password: Password to verify + :param string hashed: Hashed password. Used to extract hashing + parameters + + :returns: boolean whether hashing password with the same parameters + would match hashed value + """ + data: str = hashed + # split hashed string to extract parameters + parts: list[str] = data[1:].split("$") + rounds: int + + if len(parts) == 4: + _, rounds_str, salt_str, digest_str = parts + # Convert salt and digest back to bytes as opposite to how passlib + # serializes them + salt: bytes = password_hashers.b64s_decode( + salt_str.replace(".", "+").encode("ascii") + ) + digest: bytes = password_hashers.b64s_decode( + digest_str.replace(".", "+").encode("ascii") + ) + rounds = int(rounds_str) + else: + raise exception.PasswordValidationError("malformed password hash") + + # Prepave the kdf function with params + kdf = PBKDF2HMAC( + algorithm=Sha512.hash_algo, + length=64, + salt=salt, + iterations=rounds, + ) + + # Verify the key. + # NOTE(gtema): cryptography raises exception when key does not match + try: + kdf.verify(password, digest) + return True + except InvalidKey: + return False diff --git a/keystone/common/password_hashing.py b/keystone/common/password_hashing.py index b21a01ec7f..77616ca183 100644 --- a/keystone/common/password_hashing.py +++ b/keystone/common/password_hashing.py @@ -16,9 +16,10 @@ import itertools from oslo_log import log -import passlib.hash +from keystone.common import password_hashers from keystone.common.password_hashers import bcrypt +from keystone.common.password_hashers import pbkdf2 from keystone.common.password_hashers import scrypt from keystone.common.password_hashers import sha512_crypt import keystone.conf @@ -28,14 +29,16 @@ from keystone.i18n import _ CONF = keystone.conf.CONF LOG = log.getLogger(__name__) -SUPPORTED_HASHERS = frozenset( - [ - passlib.hash.pbkdf2_sha512, - scrypt.Scrypt, - bcrypt.Bcrypt, - bcrypt.Bcrypt_sha256, - sha512_crypt.Sha512_crypt, - ] +SUPPORTED_HASHERS: frozenset[type[password_hashers.PasswordHasher]] = ( + frozenset( + [ + scrypt.Scrypt, + bcrypt.Bcrypt, + bcrypt.Bcrypt_sha256, + sha512_crypt.Sha512_crypt, + pbkdf2.Sha512, + ] + ) ) _HASHER_NAME_MAP = {hasher.name: hasher for hasher in SUPPORTED_HASHERS} @@ -160,9 +163,6 @@ def hash_password(password: str) -> str: if CONF.identity.password_hash_rounds: params['rounds'] = CONF.identity.password_hash_rounds - if hasher is passlib.hash.pbkdf2_sha512: - if CONF.identity.salt_bytesize: - params["salt_size"] = CONF.identity.salt_bytesize if hasher is scrypt.Scrypt: params["n"] = 16 if CONF.identity.scrypt_block_size: @@ -176,5 +176,12 @@ def hash_password(password: str) -> str: return bcrypt.Bcrypt.hash(password_utf8, **params) elif hasher is bcrypt.Bcrypt_sha256: return bcrypt.Bcrypt_sha256.hash(password_utf8, **params) - - return hasher.using(**params).hash(password_utf8) + elif hasher is pbkdf2.Sha512: + if CONF.identity.salt_bytesize: + params["salt_size"] = CONF.identity.salt_bytesize + return pbkdf2.Sha512.hash(password_utf8, **params) + else: + raise RuntimeError( + _('Password Hash Algorithm %s not implemented') + % CONF.identity.password_hash_algorithm + ) diff --git a/keystone/tests/unit/common/test_password_hashing.py b/keystone/tests/unit/common/test_password_hashing.py index 677a495590..49bb5fcc7e 100644 --- a/keystone/tests/unit/common/test_password_hashing.py +++ b/keystone/tests/unit/common/test_password_hashing.py @@ -15,7 +15,7 @@ import secrets import string from oslo_config import fixture as config_fixture -import passlib +import passlib.hash from keystone.common import password_hashing import keystone.conf @@ -154,3 +154,35 @@ class TestPasswordHashing(unit.BaseTestCase): self.assertTrue( password_hashing.check_password(password, hashed_passlib) ) + + def test_pbkdf2_sha512(self): + self.config_fixture.config(strict_password_check=True) + self.config_fixture.config( + group="identity", password_hash_algorithm="pbkdf2_sha512" + ) + self.config_fixture.config(group="identity", max_password_length="96") + # Do few iterations to process different inputs + for _ in range(self.ITERATIONS): + password: str = "".join( # type: ignore + secrets.choice(string.printable) + for i in range(random.randint(1, 96)) + ) + hashed = password_hashing.hash_password(password) + self.assertTrue(password_hashing.check_password(password, hashed)) + + def test_pbkdf2_sha512_passlib_compat(self): + self.config_fixture.config(strict_password_check=True) + self.config_fixture.config( + group="identity", password_hash_algorithm="pbkdf2_sha512" + ) + self.config_fixture.config(group="identity", max_password_length="72") + # Do few iterations to process different inputs + for _ in range(self.ITERATIONS): + password: str = "".join( # type: ignore + secrets.choice(string.printable) + for i in range(random.randint(1, 72)) + ) + hashed_passlib = passlib.hash.pbkdf2_sha512.hash(password) + self.assertTrue( + password_hashing.check_password(password, hashed_passlib) + )