Merge "Switch to cryptography for pbkdf2_sha512 password hashing"

This commit is contained in:
Zuul 2024-10-04 16:40:22 +00:00 committed by Gerrit Code Review
commit e2b174a933
4 changed files with 173 additions and 17 deletions

View File

@ -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"

View File

@ -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

View File

@ -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
)

View File

@ -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)
)