2 plans (2 waves): Wave 0 xfail stubs for test_auth_fgp.py (4 FGP tests), Wave 1 production implementation (_compute_fgp helper, create_access_token fgp claim, get_current_user fgp validation, login/refresh call-site updates, test promotion) + version bump to 0.1.3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
22 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.4 | 02 | execute | 1 |
|
|
true |
|
|
Purpose: Closes the "No Token Fingerprint / Token Binding" concern in CONCERNS.md. A stolen access token can only be replayed from the same User-Agent + Accept-Language context in which it was issued. Output: Three production files modified (~27 lines total), one test file promoted (4 tests now passing).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md @.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md @.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md @.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-SUMMARY.mdFrom backend/services/auth.py:
Existing imports at lines 18-39 — both import hashlib (line 21) and import hmac (line 22) already present.
Current create_access_token signature (line 87):
def create_access_token(user_id: str, role: str) -> str:
Existing payload dict (lines 93-99):
payload = {
"sub": str(user_id),
"role": role,
"typ": "access",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
"jti": str(uuid.uuid4()),
}
Existing hmac.compare_digest usage (line ~419):
if hmac.compare_digest(candidate_suffix.upper(), suffix):
From backend/deps/auth.py:
Existing imports (lines 23-33) — hmac NOT imported. Add import hmac here.
from services import auth as auth_service at line 32 — use auth_service._compute_fgp(...) for cross-module call.
get_current_user signature (lines 41-44) — request: Request already present.
End of user_nbf block (line 85):
# ── end user_nbf check ──────────────────────────────────────────────────────
Line 87 (first line AFTER the block):
try:
user_uuid = uuid.UUID(payload["sub"])
Insert fgp block BETWEEN line 85 and line 87.
From backend/api/auth.py:
Login call site (line 290):
access_token = auth_service.create_access_token(str(user.id), user.role)
Refresh call site (line 364):
access_token = auth_service.create_access_token(user_id_str, user.role)
Both handlers already have request: Request in their signature.
From backend/tests/test_auth_deps.py: FakeRedis import: from tests.test_auth_api import FakeRedis make_test_app() sets app.state.redis = FakeRedis() _create_user(db_session, role="user", is_active=True) helper pattern auth_client fixture uses ASGITransport + AsyncClient
For FGP-03 (manual token without fgp claim):
Import import jwt as _jwt, import base64, import time
Decode private key: base64.b64decode(settings.jwt_private_key).decode()
Encode: _jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
payload_no_fgp must contain sub, role, typ="access", iat (as datetime or int), exp, jti
Use datetime.now(timezone.utc) for iat, iat + timedelta(minutes=15) for exp, str(uuid.uuid4()) for jti
Edit 1 — Insert `_compute_fgp` helper function.
Insertion point: immediately before the `def create_access_token` line (currently line 87), after the `# ── JWT helpers ──...` comment.
Function body per D-04: `hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), hashlib.sha256).hexdigest()[:16]`
Add a one-line docstring: "Return 16-char hex fingerprint binding a token to its client context (D-04)."
No new imports are needed — `import hmac` and `import hashlib` already exist at lines 21-22.
Edit 2 — Extend create_access_token signature (D-05) and embed fgp claim in payload.
Change signature from `def create_access_token(user_id: str, role: str) -> str:` to:
`def create_access_token(user_id: str, role: str, user_agent: str = "", accept_lang: str = "") -> str:`
Add `"fgp": _compute_fgp(user_agent, accept_lang)` as a new key in the payload dict, alongside the existing sub/role/typ/iat/exp/jti keys.
Do NOT modify the return statement or any other logic in the function.
Edit 1 — Add `import hmac` to the imports block.
Insert `import hmac` after `import logging` and before `import uuid` (maintain alphabetical order within stdlib imports). Do not duplicate — verify `import hmac` is not already present before adding.
Edit 2 — Insert fgp validation block.
Insertion point: after the `# ── end user_nbf check ──────────────────────────────────────────────────────` comment line and before the next `try:` block (the one that does `user_uuid = uuid.UUID(payload["sub"])`).
The block to insert (per D-06 and RESEARCH.md Pattern 3):
# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
# Validates the fgp claim embedded by create_access_token. Empty claim means
# the token predates Phase 7.4 — allow gracefully (migration window, D-06).
# NOT wrapped in try/except: _compute_fgp is pure computation with no I/O.
fgp_claim = payload.get("fgp", "")
if fgp_claim:
fgp_actual = auth_service._compute_fgp(
request.headers.get("User-Agent", ""),
request.headers.get("Accept-Language", ""),
)
if not hmac.compare_digest(fgp_claim, fgp_actual):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token fingerprint mismatch",
headers={"WWW-Authenticate": "Bearer"},
)
# ── end fgp check ───────────────────────────────────────────────────────────
Do NOT wrap this block in a try/except. Do NOT modify any other part of get_current_user.
The `auth_service._compute_fgp(...)` call uses the existing `from services import auth as auth_service` import at line 32 — no new import needed.
test_auth_fgp.py promoted tests:
- FGP-01 test_fgp_match_returns_200: token issued with user_agent="Mozilla/5.0" accept_lang="en"; request sent with same headers → 200
- FGP-02 test_fgp_mismatch_returns_401: token issued with user_agent="Mozilla/5.0" accept_lang="en"; request sent with user_agent="different-agent" → 401 and response JSON detail == "Token fingerprint mismatch"
- FGP-03 test_no_fgp_claim_allowed: token manually crafted with PyJWT (no "fgp" key in payload); request sent without extra headers → 200
- FGP-04 test_missing_headers_empty_string_binding: token issued with no user_agent/accept_lang args (empty defaults); request sent with no User-Agent or Accept-Language headers → 200
Edit 2 — backend/api/auth.py, refresh call site.
Find: `access_token = auth_service.create_access_token(user_id_str, user.role)`
Replace with:
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
Edit 3 — backend/tests/test_auth_fgp.py: add required imports and replace stub bodies.
Edit 4 — Version bump (per CLAUDE.md version bump rule: patch increment for plans shipping user-facing auth changes).
In backend/main.py: change `version="0.1.2"` to `version="0.1.3"`.
In frontend/package.json: change `"version": "0.1.2"` to `"version": "0.1.3"`.
Add to the imports block (if not already present from Wave 0):
import base64
import uuid as _uuid
import jwt as _jwt
from datetime import datetime, timezone, timedelta
from config import settings
For FGP-01 (test_fgp_match_returns_200):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with user_agent="Mozilla/5.0" and accept_lang="en", send GET /test/me with headers Authorization + User-Agent: Mozilla/5.0 + Accept-Language: en, assert status_code == 200.
For FGP-02 (test_fgp_mismatch_returns_401):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with user_agent="Mozilla/5.0" and accept_lang="en", send GET /test/me with Authorization + User-Agent: different-agent + Accept-Language: en, assert status_code == 401, assert response.json()["detail"] == "Token fingerprint mismatch".
For FGP-03 (test_no_fgp_claim_allowed):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, manually craft JWT payload dict with keys sub=str(user.id), role="user", typ="access", iat=datetime.now(timezone.utc), exp=datetime.now(timezone.utc)+timedelta(minutes=15), jti=str(_uuid.uuid4()) — do NOT include "fgp" key. Encode using _jwt.encode(payload_no_fgp, base64.b64decode(settings.jwt_private_key).decode(), algorithm="ES256"). Send GET /test/me with only Authorization header. Assert status_code == 200.
For FGP-04 (test_missing_headers_empty_string_binding):
Remove @pytest.mark.xfail decorator. Keep @pytest.mark.asyncio.
Body: create user, issue token with no user_agent/accept_lang args (defaults to ""), send GET /test/me with only Authorization header (no User-Agent, no Accept-Language), assert status_code == 200.
All four tests import create_access_token inline: `from services.auth import create_access_token`
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| client → API (login/refresh) | Client headers are untrusted input; HMAC treats them as opaque bytes — no injection vector |
| JWT payload → get_current_user | fgp claim extracted from decoded (signature-verified) JWT; attacker cannot forge without ES256 private key |
| fgp comparison | Fixed-length (16-char hex) strings compared with hmac.compare_digest — timing-safe |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07.4-01 | Spoofing | access token replay from different device | mitigate | fgp mismatch → HTTP 401; attacker must match UA + Accept-Language from original context |
| T-07.4-02 | Information Disclosure | timing attack on fingerprint comparison | mitigate | hmac.compare_digest (constant-time) — SEC-06 requirement; already established in codebase |
| T-07.4-03 | Spoofing | empty-string collision (CLI clients share one fingerprint) | accept | D-02 deliberate design decision: CLI tools with no UA headers bind to fgp("","") and are internally consistent; equivalent to pre-7.4 security for those clients |
| T-07.4-04 | Tampering | fgp HTTPException swallowed by broad except | mitigate | fgp block placed OUTSIDE try/except (pure computation, no I/O); not wrapped in broad except so 401 always propagates |
| T-07.4-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed; stdlib hmac/hashlib only |
| </threat_model> |
<success_criteria>
- _compute_fgp helper exists in backend/services/auth.py with correct HMAC-SHA256 formula (per D-04)
- create_access_token embeds "fgp" claim in every issued JWT (per D-05)
- get_current_user validates fgp with hmac.compare_digest; empty claim allowed; mismatch raises 401 "Token fingerprint mismatch" (per D-06)
- Login and refresh call sites in api/auth.py pass User-Agent and Accept-Language headers (per D-05)
- All 4 FGP tests pass: FGP-01 (match→200), FGP-02 (mismatch→401), FGP-03 (no claim→200), FGP-04 (missing headers→200)
- Full pytest suite exits 0 with zero failures
- backend/main.py and frontend/package.json both show version 0.1.3 </success_criteria>