Files
kite/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md
T

107 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Context
**Gathered:** 2026-06-06
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 7.4 adds a `fgp` (fingerprint) claim to every issued access token. The claim is a 16-char hex prefix of `HMAC-SHA256(SECRET_KEY, User-Agent + Accept-Language)`. In `get_current_user`, the fingerprint is recomputed from the incoming request headers and compared with `hmac.compare_digest`. A mismatch results in HTTP 401.
This limits the replay window of a stolen access token to the original device/browser context. No schema migrations, no new endpoints, no frontend changes.
</domain>
<decisions>
## Implementation Decisions
### HMAC Key
- **D-01:** Use `settings.secret_key` (`SECRET_KEY` env var) as the HMAC key. This was explicitly reserved for Phase 7.4 fingerprinting in Phase 7.3 D-03. No new env var is needed.
### Missing Header Behavior
- **D-02:** When `User-Agent` or `Accept-Language` is absent, use an empty string (`""`) as the fallback value for that header. The `fgp` claim is **always** computed and always validated — there is no "skip" path. This means CLI tools, Postman, and API clients receive tokens that bind to `fgp("", "")` or similar, and their requests continue to work as long as they consistently send the same (possibly absent) headers.
### Mismatch Enforcement
- **D-03:** A fingerprint mismatch raises HTTP 401 immediately with detail `"Token fingerprint mismatch"`. No soft/log-only mode. The protection is meaningless unless enforced.
### Fingerprint Computation Function
- **D-04:** Define a module-level helper `_compute_fgp(user_agent: str, accept_lang: str) -> str` in `backend/services/auth.py`. Returns `hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16]`. Centralises the logic; both `create_access_token` and `get_current_user` call the same function.
### create_access_token Signature Change
- **D-05:** `create_access_token(user_id, role)` gains two new parameters: `user_agent: str = ""` and `accept_lang: str = ""`. All callers pass request headers through. The default empty string means the function signature is backward-compatible with any test that doesn't yet pass headers.
### Validation in get_current_user
- **D-06:** After the user_nbf Redis check (Phase 7.2), add the fgp validation block. Extract `fgp_claim = payload.get("fgp", "")`. Recompute `fgp_actual = _compute_fgp(request.headers.get("User-Agent", ""), request.headers.get("Accept-Language", ""))`. If `fgp_claim` is non-empty and `not hmac.compare_digest(fgp_claim, fgp_actual)` → raise HTTP 401. If `fgp_claim` is empty (tokens issued before this phase), allow the request — graceful migration window.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Token issuance — target function
- `backend/services/auth.py` line 87 — `create_access_token(user_id, role)`: add `user_agent=""` and `accept_lang=""` params; compute `fgp` claim here using `_compute_fgp`
- `backend/services/auth.py` line 22 — `import hmac` already present; add `import hashlib` if not already there (needed for `sha256` digestmod)
### Token validation — target function
- `backend/deps/auth.py` line 41 — `get_current_user`: add fgp validation block after the `user_nbf` check (line 86); uses `request.headers.get(...)` (request already in signature)
### Callers of create_access_token (must be updated to pass headers)
- `backend/api/auth.py` — login handler (issues new access token after credential check)
- `backend/api/auth.py` — refresh handler (issues new access token when rotating refresh token)
- Any other site that calls `create_access_token` — grep for `create_access_token(` to find all callers
### Config — HMAC key
- `backend/config.py` line 31 — `secret_key: str = "CHANGEME"` — this is `settings.secret_key`; no new field needed
### CLAUDE.md security requirement
- `CLAUDE.md` §"Login token hardening" — mandates `fgp` claim = HMAC of `User-Agent + Accept-Language`, validated on every request
- `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding" — original risk description and fix approach
### Phase 7.2 pattern (user_nbf check — structural reference for placement)
- `.planning/phases/07.2-security-jti-claim-redis-access-token-revocation-inserted/07.2-CONTEXT.md` — fgp check must be placed AFTER the user_nbf block; follow the same fail-pattern (HTTPException guard + broad except)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `hmac` module already imported in `services/auth.py:22` — add `hashlib` import for SHA-256 digestmod
- `request.headers.get("User-Agent", "")` pattern is idiomatic FastAPI; `request: Request` is already in `get_current_user`'s signature
- `hmac.compare_digest` already used in `services/auth.py:419` for backup code comparison — same pattern applies here
### Established Patterns
- `user_nbf` check in `deps/auth.py:6285` — exact structural pattern to copy: `try / except HTTPException: raise / except Exception as exc: _logger.warning(...)`. Note: fgp validation does NOT use fail-open (unlike NBF) — it should raise inside the try block unconditionally on mismatch, not be swallowed by a broad except.
- `create_access_token` payload dict in `services/auth.py:93` — add `"fgp": fgp_value` alongside existing `sub`, `role`, `typ`, `iat`, `exp`, `jti` claims
### Integration Points
- `backend/api/auth.py` login handler — currently calls `create_access_token(str(user.id), user.role)`; update to pass `request.headers.get("User-Agent", "")` and `request.headers.get("Accept-Language", "")`. The login handler already receives `request: Request`.
- `backend/api/auth.py` refresh handler — same update required; the refresh endpoint also has `request: Request`
- `backend/deps/auth.py:86` — fgp check inserts right after the `user_nbf` block ends, before the `uuid.UUID(payload["sub"])` parse
</code_context>
<specifics>
## Specific Ideas
- Helper function signature: `def _compute_fgp(user_agent: str, accept_lang: str) -> str` — module-private, defined once, called from both `create_access_token` and `get_current_user`'s validation block via the auth service.
- The `fgp` check in `get_current_user` should be structured so that **tokens without an `fgp` claim are allowed** (graceful forward migration: existing logged-in sessions issued before this phase don't instantly break). Only tokens that carry an `fgp` claim get it validated.
- Test coverage must include: (1) token with correct fgp → 200, (2) token with wrong fgp → 401, (3) token without fgp claim → 200 (migration grace), (4) missing User-Agent → empty-string binding works.
</specifics>
<deferred>
## Deferred Ideas
- **Key rotation for SECRET_KEY**: A production key-rotation process for `SECRET_KEY` would invalidate all fgp bindings and refresh tokens simultaneously. Worth documenting in a RUNBOOK but not in scope here.
- **Per-request fingerprint rotation** (device key pinning, stronger binding): Future enhancement — not needed for v1.
</deferred>
---
*Phase: 07.4-security-token-fingerprinting-token-binding-inserted*
*Context gathered: 2026-06-06*