From 39be809b1ed4d098dcaa79228297d6470ee369b5 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 23 Sep 2026 18:21:50 +0200 Subject: [PATCH] =?UTF-8?q?fixup!=20=E2=9A=A1=EF=B8=8F(backend)=20hash=20a?= =?UTF-8?q?pplication=20secrets=20with=20SHA-256?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/hashers.py | 32 +++++++++++-------- .../tests/test_application_secret_hashers.py | 17 ++++++---- 2 files changed, 29 insertions(+), 20 deletions(-) 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()