Files
kite/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-PLAN.md
T
curo1305andClaude Sonnet 4.6 22fcb53d9d docs(07.4): create phase plan — token fingerprinting / token binding
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>
2026-06-06 21:44:21 +02:00

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
07.4-01
backend/services/auth.py
backend/deps/auth.py
backend/api/auth.py
backend/tests/test_auth_fgp.py
backend/main.py
frontend/package.json
true
FGP-CONCERN
truths artifacts key_links
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
path provides contains
backend/services/auth.py _compute_fgp helper + extended create_access_token signature _compute_fgp
path provides contains
backend/deps/auth.py fgp validation block after user_nbf check Token fingerprint mismatch
path provides contains
backend/api/auth.py login + refresh call sites pass request headers user_agent=request.headers.get
path provides contains
backend/tests/test_auth_fgp.py 4 promoted tests (FGP-01..04) — all passing test_fgp_match_returns_200
from to via pattern
backend/api/auth.py (login handler) backend/services/auth.create_access_token user_agent=request.headers.get('User-Agent',''), accept_lang=request.headers.get('Accept-Language','') user_agent=request.headers.get
from to via pattern
backend/deps/auth.get_current_user backend/services/auth._compute_fgp auth_service._compute_fgp(...) + hmac.compare_digest 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).

<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.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.

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`
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 - backend/main.py version string incremented to 0.1.3 - frontend/package.json version field incremented to 0.1.3

<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>
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

<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>
Create `.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-02-SUMMARY.md` when done