diff --git a/src/backend/core/hashers.py b/src/backend/core/hashers.py index aca4d6719..c17b9597a 100644 --- a/src/backend/core/hashers.py +++ b/src/backend/core/hashers.py @@ -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$$. -CLIENT_SECRET_HASH_PATTERN = re.compile( - rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}" - r"(?P[a-zA-Z0-9]{22})\$(?P[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$. +CLIENT_SECRET_HASH_PATTERN = re.compile( + rf"{re.escape(CLIENT_SECRET_HASH_PREFIX)}(?P[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)) diff --git a/src/backend/core/tests/test_application_secret_hashers.py b/src/backend/core/tests/test_application_secret_hashers.py index d64ff4e7e..5bc26b49e 100644 --- a/src/backend/core/tests/test_application_secret_hashers.py +++ b/src/backend/core/tests/test_application_secret_hashers.py @@ -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()