Skip to content

Commit c108316

Browse files
committed
fix(security): prevent username enumeration via salt endpoint
- Salt endpoint now returns 200 with deterministic fake salt for non-existent users instead of 404 - Fake salts generated via HMAC-SHA256(SECRET_KEY, username) truncated to 16 bytes, matching real salt format exactly - Response shape is uniform: {salt, has_zk_auth: true, duress_salt} for all users (real, fake, pre-migration) - has_zk_auth always returns true to prevent info leak - duress_salt key always present (null when not configured) - Applied to both zero_knowledge.py view and services.py layer
1 parent 2c19c4e commit c108316

File tree

2 files changed

+49
-30
lines changed

2 files changed

+49
-30
lines changed

backend/api/features/auth/services.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,41 @@ class AuthService:
3030
def get_user_salt(username: str) -> dict:
3131
"""
3232
Get encryption salt(s) for a user.
33-
Returns salt, duress_salt, and whether ZK auth is set up.
33+
Returns a consistent response regardless of whether the user exists,
34+
preventing username enumeration.
3435
"""
3536
try:
3637
user = User.objects.get(username__iexact=username)
3738
profile = user.userprofile
3839

3940
if not profile.encryption_salt:
40-
return {'error': 'User has no encryption salt', 'status': 404}
41+
fake_salt = AuthService._generate_deterministic_fake_salt(username)
42+
return {'salt': fake_salt, 'has_zk_auth': True, 'duress_salt': None}
4143

4244
response = {
4345
'salt': profile.encryption_salt,
44-
'has_zk_auth': bool(profile.auth_hash)
46+
'has_zk_auth': True,
47+
'duress_salt': profile.duress_salt or None,
4548
}
4649

47-
if profile.duress_salt:
48-
response['duress_salt'] = profile.duress_salt
49-
5050
return response
5151

52-
except User.DoesNotExist:
53-
return {'error': 'Salt not found', 'status': 404}
54-
except UserProfile.DoesNotExist:
55-
return {'error': 'Salt not found', 'status': 404}
52+
except (User.DoesNotExist, UserProfile.DoesNotExist):
53+
fake_salt = AuthService._generate_deterministic_fake_salt(username)
54+
return {'salt': fake_salt, 'has_zk_auth': True, 'duress_salt': None}
55+
56+
@staticmethod
57+
def _generate_deterministic_fake_salt(username: str) -> str:
58+
"""Generate a consistent fake salt for non-existent users."""
59+
import hashlib
60+
import base64
61+
from django.conf import settings
62+
digest = hmac.new(
63+
settings.SECRET_KEY.encode(),
64+
f"fake_salt:{username.lower()}".encode(),
65+
hashlib.sha256
66+
).digest()[:16] # Truncate to 16 bytes to match real salt length
67+
return base64.b64encode(digest).decode()
5668

5769
@staticmethod
5870
def register_user(username: str, email: str, auth_hash: str, salt: str,

backend/api/features/auth/zero_knowledge.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -320,15 +320,9 @@ class ZeroKnowledgeGetSaltView(APIView):
320320
GET /api/zk/salt/?username=xxx
321321
322322
Get the encryption salt(s) for a user (needed for client-side key derivation).
323-
This is public information (safe to expose).
324323
325-
Returns:
326-
- salt: Primary encryption salt
327-
- duress_salt: Duress mode salt (if duress is configured)
328-
- has_zk_auth: Whether zero-knowledge auth is set up
329-
330-
Note: Both salts are public info. Client must try BOTH when logging in
331-
to support duress mode while maintaining zero-knowledge.
324+
Returns a consistent response regardless of whether the user exists,
325+
preventing username enumeration attacks.
332326
"""
333327
permission_classes = [AllowAny]
334328

@@ -343,25 +337,38 @@ def get(self, request):
343337
profile = user.userprofile
344338

345339
if not profile.encryption_salt:
346-
return Response({'error': 'User has no encryption salt'}, status=status.HTTP_404_NOT_FOUND)
340+
fake_salt = self._generate_deterministic_fake_salt(username)
341+
return Response({'salt': fake_salt, 'has_zk_auth': True, 'duress_salt': None})
347342

348343
response_data = {
349344
'salt': profile.encryption_salt,
350-
'has_zk_auth': bool(profile.auth_hash) # Let client know if ZK auth is set up
345+
'has_zk_auth': True,
346+
'duress_salt': profile.duress_salt or None,
351347
}
352348

353-
# Include duress_salt if configured (this is public info, safe to expose)
354-
# Client needs both to try login with either password
355-
if profile.duress_salt:
356-
response_data['duress_salt'] = profile.duress_salt
357-
358349
return Response(response_data)
359350

360-
except User.DoesNotExist:
361-
# Don't reveal if user exists - return generic error
362-
return Response({'error': 'Salt not found'}, status=status.HTTP_404_NOT_FOUND)
363-
except UserProfile.DoesNotExist:
364-
return Response({'error': 'Salt not found'}, status=status.HTTP_404_NOT_FOUND)
351+
except (User.DoesNotExist, UserProfile.DoesNotExist):
352+
fake_salt = self._generate_deterministic_fake_salt(username)
353+
return Response({'salt': fake_salt, 'has_zk_auth': True, 'duress_salt': None})
354+
355+
@staticmethod
356+
def _generate_deterministic_fake_salt(username: str) -> str:
357+
"""Generate a consistent fake salt for non-existent users.
358+
359+
Uses HMAC-SHA256 keyed with SECRET_KEY so the output is:
360+
- Deterministic per username (same fake salt on repeated requests)
361+
- Indistinguishable from real base64-encoded salts
362+
- Unpredictable without knowing SECRET_KEY
363+
"""
364+
from django.conf import settings
365+
import base64
366+
digest = hmac.new(
367+
settings.SECRET_KEY.encode(),
368+
f"fake_salt:{username.lower()}".encode(),
369+
hashlib.sha256
370+
).digest()[:16] # Truncate to 16 bytes to match real salt length
371+
return base64.b64encode(digest).decode()
365372

366373

367374
class ZeroKnowledgeChangePasswordView(APIView):

0 commit comments

Comments
 (0)