Skip to content

Commit

Permalink
Use a fast key hasher instead of password hashers
Browse files Browse the repository at this point in the history
  • Loading branch information
davidfischer committed Sep 1, 2023
1 parent 13fe987 commit a59134c
Show file tree
Hide file tree
Showing 3 changed files with 80 additions and 5 deletions.
53 changes: 49 additions & 4 deletions src/rest_framework_api_key/crypto.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import hashlib
import typing

from django.contrib.auth.hashers import check_password, make_password
from django.utils.crypto import get_random_string
from django.contrib.auth.hashers import (
BasePasswordHasher,
check_password,
make_password,
)
from django.utils.crypto import constant_time_compare, get_random_string


def concatenate(left: str, right: str) -> str:
Expand All @@ -13,7 +18,36 @@ def split(concatenated: str) -> typing.Tuple[str, str]:
return left, right


class Sha512ApiKeyHasher(BasePasswordHasher):
"""
An API key hasher using the sha512 algorithm.
This hasher should *NEVER* be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing
high entropy, randomly generated API keys.
"""

algorithm = "sha512"

def salt(self) -> str:
"""No need for a salt on a high entropy key."""
return ""

def encode(self, password: str, salt: str) -> str:
if salt != "":
raise ValueError("salt is unnecessary for high entropy API tokens.")
hash = hashlib.sha512(password.encode()).hexdigest()
return "%s$$%s" % (self.algorithm, hash)

def verify(self, password: str, encoded: str) -> bool:
encoded_2 = self.encode(password, "")
return constant_time_compare(encoded, encoded_2)


class KeyGenerator:

preferred_hasher = Sha512ApiKeyHasher()

def __init__(self, prefix_length: int = 8, secret_key_length: int = 32):
self.prefix_length = prefix_length
self.secret_key_length = secret_key_length
Expand All @@ -25,7 +59,7 @@ def get_secret_key(self) -> str:
return get_random_string(self.secret_key_length)

def hash(self, value: str) -> str:
return make_password(value)
return make_password(value, hasher=self.preferred_hasher)

def generate(self) -> typing.Tuple[str, str, str]:
prefix = self.get_prefix()
Expand All @@ -35,4 +69,15 @@ def generate(self) -> typing.Tuple[str, str, str]:
return key, prefix, hashed_key

def verify(self, key: str, hashed_key: str) -> bool:
return check_password(key, hashed_key)
if self.using_preferred_hasher(hashed_key):
# New simpler hasher
result = self.preferred_hasher.verify(key, hashed_key)
else:
# Slower password hashers from Django
# If verified, these will be transparently updated to the preferred hasher
result = check_password(key, hashed_key)

return result

def using_preferred_hasher(self, hashed_key: str) -> bool:
return hashed_key.startswith(f"{self.preferred_hasher.algorithm}$$")
14 changes: 13 additions & 1 deletion src/rest_framework_api_key/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,19 @@ def _has_expired(self) -> bool:
has_expired = property(_has_expired)

def is_valid(self, key: str) -> bool:
return type(self).objects.key_generator.verify(key, self.hashed_key)
key_generator = type(self).objects.key_generator
valid = key_generator.verify(key, self.hashed_key)

# Transparently update the key to use the preferred hasher
# if it is using an outdated hasher.
if valid and not key_generator.using_preferred_hasher(self.hashed_key):
new_hashed_key = key_generator.hash(key)
type(self).objects.filter(prefix=self.prefix).update(
id=concatenate(self.prefix, new_hashed_key),
hashed_key=new_hashed_key,
)

return valid

def clean(self) -> None:
self._validate_revoked()
Expand Down
18 changes: 18 additions & 0 deletions tests/test_hashers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import pytest

from rest_framework_api_key.crypto import Sha512ApiKeyHasher


def test_sha512hasher_encode() -> None:
hasher = Sha512ApiKeyHasher()

key = "test"
hashed_key = hasher.encode(key, "")
assert hasher.verify(key, hashed_key)
assert not hasher.verify("not-test", hashed_key)


def test_sha512hasher_invalid_salt() -> None:
hasher = Sha512ApiKeyHasher()
with pytest.raises(ValueError):
hasher.encode("test", "salt")

0 comments on commit a59134c

Please sign in to comment.