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:
@@ -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.
|
**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
|
#### 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.
|
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.
|
||||||
|
|||||||
@@ -44,11 +44,21 @@ def sign_session_token(token: str, secret: str) -> str:
|
|||||||
def unwrap_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.
|
"""Verify and return the raw token from a signed session token.
|
||||||
|
|
||||||
If the token has no signature component, it is returned unchanged. This
|
All tokens must carry a valid HMAC-SHA256 signature. Tokens without
|
||||||
preserves compatibility with existing raw session tokens stored in the DB.
|
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:
|
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)
|
raw_token, signature = token.rsplit(SESSION_TOKEN_SIGNATURE_SEPARATOR, 1)
|
||||||
expected_signature = _session_token_signature(raw_token, secret)
|
expected_signature = _session_token_signature(raw_token, secret)
|
||||||
|
|||||||
Reference in New Issue
Block a user