Merge "Switch to cryptography for pbkdf2_sha512 password hashing"
This commit is contained in:
commit
e2b174a933
@ -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"
|
||||
|
116
keystone/common/password_hashers/pbkdf2.py
Normal file
116
keystone/common/password_hashers/pbkdf2.py
Normal 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
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user