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>
This commit is contained in:
2026-04-26 15:02:02 +02:00
parent c2348d7075
commit a768a2d303
2 changed files with 72 additions and 3 deletions

View File

@@ -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:
```
<raw_token>.<signature>
```
where:
- `<raw_token>` 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`).
- `<signature>` is the HMAC-SHA256 hex digest of `<raw_token>` 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.