diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 13db097..b03c6af 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -506,7 +506,15 @@ Before any phase is marked complete, all three gates must pass: **Depends on**: Phase 7.3 **Requirements**: Tracked in `.planning/codebase/CONCERNS.md` §"No Token Fingerprint / Token Binding" -**Status:** Not planned yet +**Plans**: 2 plans + +**Wave 0** — Test scaffolds (no production code) + +- [ ] 07.4-01-PLAN.md — Wave 0 xfail stubs: test_auth_fgp.py (4 stubs covering FGP-01..04) + +**Wave 1** *(blocked on Wave 0)* — Production implementation + test promotion + +- [ ] 07.4-02-PLAN.md — _compute_fgp helper + create_access_token fgp claim + get_current_user fgp validation + login/refresh call site updates + promote all 4 FGP tests --- @@ -526,4 +534,4 @@ Before any phase is marked complete, all three gates must pass: | 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — | | 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 | | 7.3. Security: ES256 algorithm upgrade | 3/3 | Complete | 2026-06-06 | -| 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — | +| 7.4. Security: token fingerprinting / token binding | 0/2 | Planned | — | diff --git a/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-PLAN.md b/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-PLAN.md new file mode 100644 index 0000000..acaf394 --- /dev/null +++ b/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-PLAN.md @@ -0,0 +1,188 @@ +--- +phase: "07.4" +plan: "01" +type: execute +wave: 0 +depends_on: [] +files_modified: + - backend/tests/test_auth_fgp.py +autonomous: true +requirements: + - FGP-CONCERN # tracked in .planning/codebase/CONCERNS.md §"No Token Fingerprint / Token Binding" + +must_haves: + truths: + - "test_auth_fgp.py exists with exactly 4 test functions" + - "All 4 tests are decorated with @pytest.mark.xfail(strict=False, reason='not implemented yet')" + - "pytest -v reports all 4 as XFAIL — no failures, no errors" + - "No production source files are modified" + artifacts: + - path: "backend/tests/test_auth_fgp.py" + provides: "Wave 0 xfail stubs for FGP-01..FGP-04" + contains: "test_fgp_match_returns_200" + key_links: + - from: "backend/tests/test_auth_fgp.py" + to: "backend/tests/test_auth_deps.py" + via: "imports FakeRedis, copies make_test_app / auth_client / _create_user patterns" + pattern: "from tests.test_auth_api import FakeRedis" +--- + + +Create the Wave 0 test scaffold for Phase 7.4: a new test file `backend/tests/test_auth_fgp.py` containing 4 xfail stubs covering the four fingerprint behaviours (FGP-01..FGP-04). No production code is touched in this plan. + +Purpose: Establishes the Nyquist test harness before any production code changes. Follows the xfail(strict=False) Wave 0 convention established in Phases 7.2 and 7.3. +Output: `backend/tests/test_auth_fgp.py` with 4 stubs; full test suite still passes with zero failures. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +From backend/tests/test_auth_deps.py (full harness to copy): + +Imports: + import uuid + import pytest + import pytest_asyncio + from httpx import ASGITransport, AsyncClient + from fastapi import FastAPI, Depends + from sqlalchemy.ext.asyncio import AsyncSession + from tests.test_auth_api import FakeRedis + +make_test_app(): + Creates minimal FastAPI app with /test/me route wired to get_current_user. + Sets app.state.redis = FakeRedis() so NBF check and fgp check can access request.app.state.redis. + +auth_client fixture: + @pytest_asyncio.fixture + async def auth_client(db_session: AsyncSession): + app = make_test_app() + app.dependency_overrides[get_db] = lambda: db_session + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + app.dependency_overrides.clear() + +_create_user helper (no decorator — plain async function): + Inserts minimal User row; returns User ORM object. + Fields: id=uuid.uuid4(), handle, email, password_hash, role, is_active=True + +Test function pattern: + @pytest.mark.asyncio + async def test_name(auth_client, db_session): + from services.auth import create_access_token + user = await _create_user(db_session) + token = create_access_token(str(user.id), "user") + resp = await auth_client.get("/test/me", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + +conftest _patch_es256_test_keys is autouse=True at session scope — no explicit reference needed in this file. + +From backend/tests/test_auth_es256.py (xfail stub pattern for Phase 7.3): + @pytest.mark.xfail(strict=False, reason="not implemented yet") + @pytest.mark.asyncio + async def test_stub_name(...): + pytest.xfail("not implemented yet") + + + + + + + Task 1: Create test_auth_fgp.py with 4 xfail stubs (FGP-01..FGP-04) + backend/tests/test_auth_fgp.py + + - backend/tests/test_auth_deps.py — copy the full harness (make_test_app, auth_client fixture, _create_user helper, imports block). This is the structural template; copy verbatim then modify test functions only. + - backend/tests/test_auth_es256.py — xfail stub decoration pattern used in Phase 7.3. Stubs use @pytest.mark.xfail(strict=False, reason="not implemented yet") and body is only pytest.xfail("not implemented yet"). + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md §"Test Case Structure" — exact test names and behaviour descriptions for all 4 tests. + + + - FGP-01 test_fgp_match_returns_200: issues token with user_agent="Mozilla/5.0" accept_lang="en", sends request with matching headers — expects 200 + - FGP-02 test_fgp_mismatch_returns_401: issues token with user_agent="Mozilla/5.0" accept_lang="en", sends request with user_agent="different-agent" — expects 401 and detail="Token fingerprint mismatch" + - FGP-03 test_no_fgp_claim_allowed: crafts token manually without "fgp" key in payload — expects 200 (migration grace) + - FGP-04 test_missing_headers_empty_string_binding: issues token with no user_agent/accept_lang args (defaults to ""), sends request with no User-Agent/Accept-Language headers — expects 200 + + + Create backend/tests/test_auth_fgp.py. Structure: + + 1. Module docstring explaining Phase 7.4 fgp test coverage (FGP-01..04). + + 2. Imports: copy the exact imports block from test_auth_deps.py. Add these additional imports needed for FGP-03 (manual token craft): `import base64`, `import time`, `import jwt as _jwt`, and `from config import settings`. + + 3. Copy make_test_app() verbatim from test_auth_deps.py (includes app.state.redis = FakeRedis()). Do NOT add a /test/admin route — not needed here. + + 4. Copy auth_client fixture verbatim from test_auth_deps.py. + + 5. Copy _create_user helper verbatim from test_auth_deps.py. + + 6. Write 4 test functions in this order: + - test_fgp_match_returns_200 (FGP-01) + - test_fgp_mismatch_returns_401 (FGP-02) + - test_no_fgp_claim_allowed (FGP-03) + - test_missing_headers_empty_string_binding (FGP-04) + + Each test function: + - Decorated with @pytest.mark.xfail(strict=False, reason="not implemented yet") ABOVE @pytest.mark.asyncio + - Decorated with @pytest.mark.asyncio + - Accepts (auth_client, db_session) as parameters + - Body is a single line: pytest.xfail("not implemented yet") + + Do NOT write any assertion logic, token construction, or request code inside the stubs. The stub body is ONLY the pytest.xfail() call. Implementation comes in Wave 1 (Plan 07.4-02). + + 7. No production files (services/auth.py, deps/auth.py, api/auth.py) may be modified in this task. + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_auth_fgp.py -v 2>&1 | tail -20 + + + - backend/tests/test_auth_fgp.py exists + - pytest tests/test_auth_fgp.py -v shows exactly 4 tests, all reported as XFAIL + - Zero FAILED, zero ERROR entries in the output + - pytest -v (full suite) still passes with zero failures + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| test harness → test DB | xfail stubs create no DB state; no trust boundary crossed in Wave 0 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07.4-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan; stdlib only | + + + +After this plan completes: +- `pytest tests/test_auth_fgp.py -v` shows 4 XFAIL +- `pytest -v` (full suite) exits 0 with the same pass count as before this plan (411+) plus 4 new XFAIL +- No changes to backend/services/auth.py, backend/deps/auth.py, or backend/api/auth.py + + + +- `backend/tests/test_auth_fgp.py` exists with 4 test stubs (FGP-01..04) +- All 4 stubs use `@pytest.mark.xfail(strict=False, reason="not implemented yet")` +- Full test suite passes with zero failures; 4 new XFAIL added +- No production code modified + + + +Create `.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-01-SUMMARY.md` when done + diff --git a/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-PLAN.md b/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-PLAN.md new file mode 100644 index 0000000..752e282 --- /dev/null +++ b/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-PLAN.md @@ -0,0 +1,348 @@ +--- +phase: "07.4" +plan: "02" +type: execute +wave: 1 +depends_on: + - "07.4-01" +files_modified: + - backend/services/auth.py + - backend/deps/auth.py + - backend/api/auth.py + - backend/tests/test_auth_fgp.py +autonomous: true +requirements: + - FGP-CONCERN # tracked in .planning/codebase/CONCERNS.md §"No Token Fingerprint / Token Binding" + +must_haves: + truths: + - "Every issued access token contains a 'fgp' claim (16-char hex)" + - "A request presenting a token whose fgp was computed from different headers receives HTTP 401 'Token fingerprint mismatch'" + - "A token without a 'fgp' claim is accepted (migration grace — old sessions not broken)" + - "Missing User-Agent / Accept-Language headers both default to empty string and still produce a valid, consistent fingerprint" + - "All 4 FGP tests in test_auth_fgp.py pass (promoted from xfail stubs)" + - "Full pytest suite passes with zero failures" + artifacts: + - path: "backend/services/auth.py" + provides: "_compute_fgp helper + extended create_access_token signature" + contains: "_compute_fgp" + - path: "backend/deps/auth.py" + provides: "fgp validation block after user_nbf check" + contains: "Token fingerprint mismatch" + - path: "backend/api/auth.py" + provides: "login + refresh call sites pass request headers" + contains: "user_agent=request.headers.get" + - path: "backend/tests/test_auth_fgp.py" + provides: "4 promoted tests (FGP-01..04) — all passing" + contains: "test_fgp_match_returns_200" + key_links: + - from: "backend/api/auth.py (login handler)" + to: "backend/services/auth.create_access_token" + via: "user_agent=request.headers.get('User-Agent',''), accept_lang=request.headers.get('Accept-Language','')" + pattern: "user_agent=request.headers.get" + - from: "backend/deps/auth.get_current_user" + to: "backend/services/auth._compute_fgp" + via: "auth_service._compute_fgp(...) + hmac.compare_digest" + pattern: "hmac.compare_digest\\(fgp_claim, fgp_actual\\)" +--- + + +Implement token fingerprinting end-to-end: add `_compute_fgp` to `services/auth.py`, embed the `fgp` claim in every issued access token, add the fgp validation block to `get_current_user` in `deps/auth.py`, update the two call sites in `api/auth.py`, and promote all 4 xfail stubs in `test_auth_fgp.py` to passing integration tests. + +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). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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.md + + + + +From 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 + + + + + + + Task 1: Add _compute_fgp and extend create_access_token in services/auth.py + backend/services/auth.py + + - backend/services/auth.py — read lines 80-105 to see the exact current signature of create_access_token, the payload dict, and the line immediately before it (the JWT helpers comment at line 85). Insert _compute_fgp between line 85 and line 87. + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md §D-04, D-05 — locked function signature and return value formula. + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md §"backend/services/auth.py" — exact code to insert. + + + - _compute_fgp("Mozilla/5.0", "en") returns a 16-char hex string + - _compute_fgp("", "") returns a deterministic 16-char hex string (not empty, not None) + - create_access_token(user_id, role) still works without new params (backward-compatible defaults) + - create_access_token(user_id, role, user_agent="X", accept_lang="Y") embeds fgp=_compute_fgp("X","Y") in payload + - JWT payload from create_access_token contains a "fgp" key + + + Make two targeted edits to backend/services/auth.py: + + 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. + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from services.auth import create_access_token, _compute_fgp; t = create_access_token('00000000-0000-0000-0000-000000000001', 'user', user_agent='test', accept_lang='en'); import jwt, base64; from config import settings; pub = base64.b64decode(settings.jwt_public_key).decode(); p = jwt.decode(t, pub, algorithms=['ES256'], options={'verify_exp':False}); assert 'fgp' in p and len(p['fgp']) == 16, f'fgp missing or wrong length: {p}'; print('OK:', p['fgp'])" + + + - backend/services/auth.py contains `def _compute_fgp(user_agent: str, accept_lang: str) -> str:` + - backend/services/auth.py contains `"fgp": _compute_fgp(user_agent, accept_lang)` in create_access_token payload + - create_access_token signature now has `user_agent: str = ""` and `accept_lang: str = ""` parameters + - Python inline verify command prints "OK:" followed by a 16-char hex string + + + + + Task 2: Add fgp validation block to get_current_user in deps/auth.py + backend/deps/auth.py + + - backend/deps/auth.py — read lines 23-35 (imports block) and lines 62-95 (user_nbf block + lines immediately after). The fgp block inserts AFTER the `# ── end user_nbf check ──` comment (currently line 85) and BEFORE the `try: user_uuid = uuid.UUID(payload["sub"])` block (currently line 87). + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-PATTERNS.md §"backend/deps/auth.py" — exact fgp block code, exact insertion point, note that `import hmac` must be added to the imports block. + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-CONTEXT.md §D-03, D-06 — mismatch raises 401 immediately; empty fgp_claim (old tokens) is allowed. + + + - Request with token whose fgp matches recomputed value: no exception raised, flow continues normally + - Request with token whose fgp does NOT match recomputed value: raises HTTPException(401, detail="Token fingerprint mismatch") + - Token without "fgp" key in payload (payload.get("fgp","") == ""): fgp block is skipped entirely, flow continues (migration grace per D-06) + - fgp comparison uses hmac.compare_digest (constant-time, per SEC-06) + + + Make two targeted edits to backend/deps/auth.py: + + 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. + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import ast, sys; src=open('deps/auth.py').read(); ast.parse(src); print('syntax OK')" && grep -c "Token fingerprint mismatch" deps/auth.py + + + - backend/deps/auth.py contains `import hmac` in its imports block + - backend/deps/auth.py contains the string "Token fingerprint mismatch" + - backend/deps/auth.py contains `fgp_claim = payload.get("fgp", "")` + - backend/deps/auth.py contains `hmac.compare_digest(fgp_claim, fgp_actual)` + - Python syntax check passes (ast.parse exits 0) + - grep -c "Token fingerprint mismatch" deps/auth.py prints 1 + + + + + Task 3: Update login + refresh call sites in api/auth.py; promote all 4 stubs to passing tests + backend/api/auth.py, backend/tests/test_auth_fgp.py + + - backend/api/auth.py — read lines 285-295 (login call site, current: `access_token = auth_service.create_access_token(str(user.id), user.role)`) and lines 360-370 (refresh call site, current: `access_token = auth_service.create_access_token(user_id_str, user.role)`). Both handlers already have `request: Request` in their signatures. + - backend/tests/test_auth_fgp.py — read the full file (created in Wave 0). Replace all 4 stub bodies (currently only `pytest.xfail("not implemented yet")`) with real test logic per RESEARCH.md §"Test Case Structure". + - .planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.md §"Test Case Structure" and §"Pattern 4: Caller updates in api/auth.py" — exact before/after forms for both call sites and exact test logic for all 4 tests. + - backend/tests/test_auth_deps.py — confirm the FGP-03 pattern for crafting a token without fgp claim (PyJWT direct encode with ES256, import jwt as _jwt, base64.b64decode settings.jwt_private_key). + + + api/auth.py: + - Login handler: create_access_token call passes user_agent=request.headers.get("User-Agent","") and accept_lang=request.headers.get("Accept-Language","") + - Refresh handler: same header passthrough + + 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 1 — backend/api/auth.py, login call site. + Find: `access_token = auth_service.create_access_token(str(user.id), user.role)` + Replace with: + access_token = auth_service.create_access_token( + str(user.id), + user.role, + user_agent=request.headers.get("User-Agent", ""), + accept_lang=request.headers.get("Accept-Language", ""), + ) + + 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. + + 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` + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_auth_fgp.py -v && pytest tests/test_auth_api.py tests/test_auth_deps.py -x -q + + + - pytest tests/test_auth_fgp.py -v shows 4 PASSED (0 xfail, 0 failed) + - pytest tests/test_auth_api.py tests/test_auth_deps.py -x -q passes (existing auth tests unbroken) + - backend/api/auth.py login call site contains `user_agent=request.headers.get("User-Agent", "")` + - backend/api/auth.py refresh call site contains `accept_lang=request.headers.get("Accept-Language", "")` + - Full suite: cd backend && pytest -v exits 0 with zero failures + + + + + + +## 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 | + + + +After this plan completes: +1. `pytest tests/test_auth_fgp.py -v` — 4 PASSED +2. `pytest -v` — full suite exits 0 with zero failures; total count increases by 4 (previously xfail stubs are now passing tests) +3. `grep -n "fgp" backend/services/auth.py` — shows _compute_fgp definition and payload["fgp"] insertion +4. `grep -n "Token fingerprint mismatch" backend/deps/auth.py` — shows 1 match +5. `grep -n "user_agent=request.headers" backend/api/auth.py` — shows 2 matches (login + refresh) +6. Security gate: `bandit -r backend/` exits with zero HIGH findings + + + +- _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 + + + +Create `.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-SUMMARY.md` when done +