fixup! ⚡️(backend) hash application secrets with SHA-256

This commit is contained in:
lebaudantoine
2026-09-23 18:21:50 +02:00
parent 3a1a135d77
commit 39be809b1e
2 changed files with 29 additions and 20 deletions
+18 -14
View File
@@ -7,27 +7,33 @@ import hashlib
import re
from django.contrib.auth.hashers import check_password
from django.utils.crypto import constant_time_compare, get_random_string
from django.utils.crypto import constant_time_compare
from django.utils.encoding import force_bytes
CLIENT_SECRET_HASH_PREFIX = "sha256$" # noqa: S105 - format identifier, not a secret
# Accept only the salted format: sha256$<salt>$<digest>.
CLIENT_SECRET_HASH_PATTERN = re.compile(
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}"
r"(?P<salt>[a-zA-Z0-9]{22})\$(?P<digest>[0-9a-f]{64})"
CLIENT_SECRET_HASH_ALGORITHM = "sha256"
CLIENT_SECRET_HASH_VERSION = "v0"
CLIENT_SECRET_HASH_PREFIX = ( # noqa: S105 - format identifier, not a secret
f"{CLIENT_SECRET_HASH_ALGORITHM}${CLIENT_SECRET_HASH_VERSION}$"
)
# Accept only the versioned format: sha256$v0$<digest>.
CLIENT_SECRET_HASH_PATTERN = re.compile(
rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P<digest>[0-9a-f]{{64}})"
)
def _digest(raw_secret):
"""Return the hex SHA-256 digest of a raw secret."""
return hashlib.sha256(force_bytes(raw_secret)).hexdigest()
def hash_client_secret(raw_secret):
"""Hash a machine-generated application secret without key stretching."""
salt = get_random_string(22)
digest = hashlib.sha256(salt.encode() + force_bytes(raw_secret)).hexdigest()
return f"{CLIENT_SECRET_HASH_PREFIX}{salt}${digest}"
return f"{CLIENT_SECRET_HASH_PREFIX}{_digest(raw_secret)}"
def verify_client_secret(raw_secret, encoded):
"""Verify the salted application format or a legacy Django password hash."""
"""Verify the versioned application format or a legacy Django password hash."""
if raw_secret is None:
return False
@@ -37,6 +43,4 @@ def verify_client_secret(raw_secret, encoded):
if not match:
return check_password(raw_secret, encoded)
salt = match["salt"]
digest = hashlib.sha256(salt.encode() + force_bytes(raw_secret)).hexdigest()
return constant_time_compare(match["digest"], digest)
return constant_time_compare(match["digest"], _digest(raw_secret))
@@ -23,15 +23,16 @@ def test_application_hash(secret):
"""Application hashes verify correctly but are not accepted for user passwords."""
encoded = hashers.hash_client_secret(secret)
raw = secret.encode() if isinstance(secret, str) else secret
algorithm, salt, digest = encoded.split("$")
algorithm, version, digest = encoded.split("$")
assert algorithm == "sha256"
assert len(salt) == 22
assert digest == hashlib.sha256(salt.encode() + raw).hexdigest()
assert hashers.hash_client_secret(secret) != encoded
assert version == "v0"
assert digest == hashlib.sha256(raw).hexdigest()
assert hashers.hash_client_secret(secret) == encoded
assert hashers.verify_client_secret(secret, encoded)
assert not hashers.verify_client_secret("wrong", encoded)
assert not hashers.verify_client_secret(None, encoded)
assert not hashers.verify_client_secret(secret, "sha256$invalid")
assert not hashers.verify_client_secret(secret, "sha256$v1$" + digest)
assert not check_password(secret, encoded)
with pytest.raises(ValueError):
identify_hasher(encoded)
@@ -63,7 +64,7 @@ def test_token_migrates_legacy_secret_once(algorithm):
app.refresh_from_db()
migrated = app.client_secret_sha256
assert check_password(secret, app.client_secret)
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["salt"]
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(migrated)["digest"]
assert hashers.verify_client_secret(secret, migrated)
with CaptureQueriesContext(connection) as queries:
response = client.post(
@@ -167,6 +168,10 @@ def test_migration_preserves_concurrent_deletion():
"sha256$$" + "a" * 64,
"sha256$short$" + "a" * 64,
"sha256$" + "b" * 22 + "$" + "g" * 64,
"sha256$" + "b" * 22 + "$" + "a" * 64,
"sha256$v0$" + "g" * 64,
"sha256$v0$" + "a" * 63,
"sha256$v1$" + "a" * 64,
],
)
def test_prefixed_plaintext_is_hashed(secret):
@@ -176,7 +181,7 @@ def test_prefixed_plaintext_is_hashed(secret):
app.refresh_from_db()
encoded = app.client_secret_sha256
assert encoded != secret
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["salt"]
assert hashers.CLIENT_SECRET_HASH_PATTERN.fullmatch(encoded)["digest"]
assert app.check_client_secret(secret)
app.name = "Updated application"
app.save()