docs(07.4): capture phase context
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
|||||||
|
# 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:62–85` — 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*
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
# Phase 7.4: Security — Token Fingerprinting / Token Binding - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Phase:** 07.4-security-token-fingerprinting-token-binding-inserted
|
||||||
|
**Areas discussed:** HMAC key source, Missing-header behavior, Mismatch enforcement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HMAC Key Source
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| SECRET_KEY | Already in docker-compose; Phase 7.3 D-03 explicitly reserved it for this use. No new env var needed. | ✓ |
|
||||||
|
| New FGP_HMAC_KEY env var | Dedicated key — key-separation principle. Requires adding to config.py, docker-compose.yml, .env, and README. | |
|
||||||
|
|
||||||
|
**User's choice:** SECRET_KEY
|
||||||
|
**Notes:** The prior-phase decision (7.3 D-03) already pointed here; confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing-Header Behavior
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Empty string fallback | Use "" for any absent header; fgp always computed and validated. CLI tools and Postman work fine, binding to fgp("",""). | ✓ |
|
||||||
|
| Skip fingerprint check entirely | Omit fgp claim when headers absent; skip validation when claim absent. Weakens protection. | |
|
||||||
|
| Reject at issuance (401) | Login fails if User-Agent missing. Strongest binding but breaks all non-browser clients. | |
|
||||||
|
|
||||||
|
**User's choice:** Empty string fallback
|
||||||
|
**Notes:** Graceful handling — no client breakage, consistent enforcement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mismatch Enforcement
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hard 401 always | Token rejected immediately. Correct security posture. Browser updates are rare. | ✓ |
|
||||||
|
| Log-only soft mode | Log WARNING, allow request through. Safe for rollout but provides zero protection. | |
|
||||||
|
|
||||||
|
**User's choice:** Hard 401 always
|
||||||
|
**Notes:** The feature is meaningless if not enforced. Ship it enforced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Placement of fgp check in `get_current_user` (after user_nbf block)
|
||||||
|
- Migration grace: tokens without `fgp` claim allowed (forward-compat for existing sessions)
|
||||||
|
- `_compute_fgp` as a module-private helper called from both issuance and validation sites
|
||||||
|
- Test coverage cases (4 scenarios specified in CONTEXT.md specifics)
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Production key-rotation ceremony for SECRET_KEY — RUNBOOK documentation, future milestone
|
||||||
|
- Per-request device key pinning (stronger binding) — not needed for v1
|
||||||
Reference in New Issue
Block a user