Skip to content

Commit 260f96f

Browse files
committed
perf(accounts): use lower-cost Argon2 for UserCode PINs
Alarm PINs are 4-8 digits with a 10K-100M keyspace. Stretching them at the default user-password Argon2 cost (~150-300ms/verify) doesn't materially raise an offline-brute-force attacker's bar - the keyspace is so small that the entire space is recovered in minutes-to-hours even at strong cost - while the slow verify shows up as a multi- hundred-ms tax on every arm/disarm round trip from the HA MQTT alarm panel. Add UserCodeArgon2Hasher (algorithm "argon2-pin", time_cost=1, memory_cost=8 MiB, parallelism=2; ~30ms/verify) and route create_user_code / update_user_code through it. User passwords keep the strong default. Existing UserCode rows hashed under the original "argon2" algorithm continue to verify on the strong hasher with no migration required - check_password resolves by hash prefix. Combined with the PR #46 MQTT-template fix, this gets HA-driven arm/ disarm clicks down from ~2s to subsecond round-trip on the live home-lab broker.
1 parent 6698a9e commit 260f96f

4 files changed

Lines changed: 108 additions & 2 deletions

File tree

backend/accounts/hashers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from django.contrib.auth.hashers import Argon2PasswordHasher
4+
5+
6+
class UserCodeArgon2Hasher(Argon2PasswordHasher):
7+
"""
8+
Lower-cost Argon2 hasher for UserCode PINs.
9+
10+
Alarm PINs are 4-8 digits with a 10K-100M keyspace. Stretching them with
11+
the default user-password Argon2 cost (~150-300ms/verify) doesn't
12+
materially raise an offline-brute-force attacker's cost: the keyspace is
13+
so small that the entire space is recovered in minutes-to-hours even at
14+
strong cost, while the slow verify shows up as a multi-hundred-ms tax on
15+
every arm/disarm round trip through the MQTT alarm panel.
16+
17+
Calibrated for ~30ms/verify - plenty for the online-rate-limited entry
18+
path while letting clicks feel instant. The algorithm discriminator is
19+
renamed so check_password routes new and old UserCode hashes to the right
20+
hasher: existing rows hashed under the default 'argon2' algorithm continue
21+
to verify on the strong hasher with no migration required.
22+
23+
Strong-cost protection for User passwords (long, high-entropy, large
24+
keyspace) is unchanged - this hasher is only routed through explicit
25+
`make_password(..., hasher='argon2-pin')` calls in `accounts.use_cases.user_codes`.
26+
"""
27+
28+
algorithm = "argon2-pin"
29+
time_cost = 1
30+
memory_cost = 8 * 1024
31+
parallelism = 2
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from datetime import timezone as dt_timezone
5+
6+
from django.contrib.auth.hashers import check_password, get_hasher, make_password
7+
from django.test import TestCase
8+
9+
from accounts.hashers import UserCodeArgon2Hasher
10+
from accounts.models import User, UserCode
11+
from accounts.use_cases import code_validation
12+
from accounts.use_cases.user_codes import create_user_code, update_user_code
13+
14+
15+
class UserCodeArgon2HasherRegistrationTests(TestCase):
16+
def test_hasher_is_registered_under_argon2_pin_algorithm(self):
17+
hasher = get_hasher("argon2-pin")
18+
self.assertIsInstance(hasher, UserCodeArgon2Hasher)
19+
20+
def test_reduced_cost_params(self):
21+
hasher = get_hasher("argon2-pin")
22+
self.assertEqual(hasher.time_cost, 1)
23+
self.assertEqual(hasher.memory_cost, 8 * 1024)
24+
self.assertEqual(hasher.parallelism, 2)
25+
26+
27+
class UserCodeHasherUsageTests(TestCase):
28+
def setUp(self):
29+
self.user = User.objects.create_user(email="hasher@example.com", password="pass")
30+
31+
def test_create_user_code_routes_through_argon2_pin(self):
32+
code = create_user_code(user=self.user, raw_code="1234", label="kitchen")
33+
self.assertTrue(
34+
code.code_hash.startswith("argon2-pin$"),
35+
f"expected argon2-pin$ prefix, got: {code.code_hash[:30]}",
36+
)
37+
38+
def test_update_user_code_rotates_to_argon2_pin(self):
39+
code = create_user_code(user=self.user, raw_code="1234")
40+
original_hash = code.code_hash
41+
update_user_code(code=code, changes={"code": "5678"})
42+
code.refresh_from_db()
43+
self.assertNotEqual(code.code_hash, original_hash)
44+
self.assertTrue(code.code_hash.startswith("argon2-pin$"))
45+
46+
def test_check_password_round_trips_new_hash(self):
47+
hashed = make_password("4242", hasher="argon2-pin")
48+
self.assertTrue(hashed.startswith("argon2-pin$"))
49+
self.assertTrue(check_password("4242", hashed))
50+
self.assertFalse(check_password("0000", hashed))
51+
52+
def test_validates_new_argon2_pin_code_via_use_case(self):
53+
code = create_user_code(user=self.user, raw_code="9876")
54+
now = datetime(2025, 1, 1, 12, 0, tzinfo=dt_timezone.utc)
55+
result = code_validation.validate_user_code(user=self.user, raw_code="9876", now=now)
56+
self.assertEqual(result.code.id, code.id)
57+
58+
def test_legacy_argon2_hash_still_verifies(self):
59+
# Existing rows in production were hashed under the default 'argon2'
60+
# algorithm. Verify those continue to validate after the new hasher
61+
# is added - no migration required.
62+
legacy_hash = make_password("1234")
63+
self.assertTrue(legacy_hash.startswith("argon2$"))
64+
UserCode.objects.create(
65+
user=self.user,
66+
code_hash=legacy_hash,
67+
label="legacy",
68+
code_type=UserCode.CodeType.PERMANENT,
69+
pin_length=4,
70+
is_active=True,
71+
)
72+
now = datetime(2025, 1, 1, 12, 0, tzinfo=dt_timezone.utc)
73+
result = code_validation.validate_user_code(user=self.user, raw_code="1234", now=now)
74+
self.assertTrue(result.code.code_hash.startswith("argon2$"))

backend/accounts/use_cases/user_codes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def create_user_code(
3030
raw_code = (raw_code or "").strip()
3131
code = UserCode.objects.create(
3232
user=user,
33-
code_hash=make_password(raw_code),
33+
code_hash=make_password(raw_code, hasher="argon2-pin"),
3434
label=label or "",
3535
code_type=code_type,
3636
pin_length=len(raw_code),
@@ -57,7 +57,7 @@ def update_user_code(
5757
"""Update a user code and its allowed states."""
5858
if "code" in changes and changes.get("code"):
5959
raw_code = str(changes.get("code") or "").strip()
60-
code.code_hash = make_password(raw_code)
60+
code.code_hash = make_password(raw_code, hasher="argon2-pin")
6161
code.pin_length = len(raw_code)
6262

6363
if "label" in changes:

backend/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110

111111
PASSWORD_HASHERS = [
112112
"django.contrib.auth.hashers.Argon2PasswordHasher",
113+
"accounts.hashers.UserCodeArgon2Hasher",
113114
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
114115
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
115116
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",

0 commit comments

Comments
 (0)