From a768a2d303fe9fcc77a7ec0ed3737cc1ab8efff7 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 26 Apr 2026 15:02:02 +0200 Subject: [PATCH] TASK-025: Remove HMAC bypass in unwrap_session_token - Remove the early-return branch that skipped HMAC verification for unsigned tokens - Raise ValueError if the signature separator is absent - Update unwrap_session_token docstring to reflect mandatory signing requirement - Add comprehensive session token signing documentation to Backend-Development.md - Document the session token format, signing/verification pattern, and security rationale All tokens must now carry a valid HMAC-SHA256 signature. Tokens without a signature are rejected immediately. This removes the vulnerability where an attacker with database access could bypass the HMAC layer by using raw tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Backend-Development.md | 59 ++++++++++++++++++++++++++++ backend/app/services/auth_service.py | 16 ++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 35989cc..40c4a52 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -1211,6 +1211,65 @@ async def get_session( **Never use symmetric encryption** — symmetric encryption stores a key in the database or environment, which merely shifts the exposure risk. A one-way hash is the correct choice for protecting tokens. +#### 13.7.2a Session Token Signing Format — HMAC-SHA256 Integrity Protection + +**All session tokens sent to clients are signed using HMAC-SHA256.** The signed token format is: + +``` +. +``` + +where: +- `` is a 16-byte (128-bit) random hex string generated by `secrets.token_hex(16)`. +- `.` is the separator (defined in `app.utils.constants.SESSION_TOKEN_SIGNATURE_SEPARATOR`). +- `` is the HMAC-SHA256 hex digest of `` using the configured `session_secret`. + +**Example:** `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6.f7e6d5c4b3a2918f7e6d5c4b3a29180` + +**Signing and verification pattern:** + +```python +import hashlib +import hmac + +def _session_token_signature(token: str, secret: str) -> str: + """Return the HMAC-SHA256 signature for a session token.""" + return hmac.new(secret.encode(), token.encode(), hashlib.sha256).hexdigest() + +def sign_session_token(token: str, secret: str) -> str: + """Return a signed session token string for the client.""" + return f"{token}.{_session_token_signature(token, secret)}" + +def unwrap_session_token(token: str, secret: str) -> str: + """Verify and return the raw token from a signed session token. + + Raises ValueError if the token lacks a signature or signature is invalid. + """ + if "." not in token: + raise ValueError("Invalid session token.") + + raw_token, signature = token.rsplit(".", 1) + expected_signature = _session_token_signature(raw_token, secret) + if not hmac.compare_digest(expected_signature, signature): + raise ValueError("Invalid session token.") + return raw_token +``` + +**Key points:** + +1. **All tokens must be signed** — Tokens without a signature (no separator) are rejected immediately. +2. **Signature is mandatory** — The `unwrap_session_token()` function raises `ValueError` if the separator is absent. +3. **Use HMAC-SHA256** — Always use `hmac.compare_digest()` for signature verification to prevent timing attacks. +4. **Sign on login** — `login()` creates a raw token, stores it (hashed) in the database, then returns the signed token to the client. +5. **Verify on every request** — The `validate_session()` service verifies the signature by calling `unwrap_session_token()` with the `session_secret`, then looks up the raw token in the database. +6. **Session invalidation** — When upgrading from plaintext to signed tokens (TASK-022), all existing sessions must be invalidated because raw tokens will no longer be stored unencrypted. + +**Why HMAC signing is necessary:** + +- **Prevents token forgery** — An attacker cannot create a valid token without knowing the `session_secret`. +- **Works alongside hashed storage** — Even if the database is compromised (plaintext before hashing), the attacker gets only the raw token, not a signed token. A raw token without a valid signature is rejected by `unwrap_session_token()`. +- **Timing attack resistance** — `hmac.compare_digest()` compares signatures in constant time, preventing attackers from using timing differences to guess valid signatures. + #### 13.7.3 Session Cache Pluggability — Process-Local vs. Shared Backends Session validation is expensive (SQLite lookup + password verification). To improve performance, **validated session tokens are cached** using the `SessionCache` interface (`app.utils.session_cache`). The default implementation, `InMemorySessionCache`, stores cached sessions in process-local memory. diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 7ebc363..084e91d 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -44,11 +44,21 @@ def sign_session_token(token: str, secret: str) -> str: def unwrap_session_token(token: str, secret: str) -> str: """Verify and return the raw token from a signed session token. - If the token has no signature component, it is returned unchanged. This - preserves compatibility with existing raw session tokens stored in the DB. + All tokens must carry a valid HMAC-SHA256 signature. Tokens without + a signature are rejected. + + Args: + token: The signed session token in format "raw_token.signature". + secret: The HMAC secret used to verify the signature. + + Returns: + The raw token component. + + Raises: + ValueError: If the token lacks a signature or the signature is invalid. """ if SESSION_TOKEN_SIGNATURE_SEPARATOR not in token: - return token + raise ValueError("Invalid session token.") raw_token, signature = token.rsplit(SESSION_TOKEN_SIGNATURE_SEPARATOR, 1) expected_signature = _session_token_signature(raw_token, secret)