Files
kite/.planning/phases/07.4-security-token-fingerprinting-token-binding-inserted/07.4-RESEARCH.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

24 KiB
Raw Blame History

Phase 7.4: Security — Token Fingerprinting / Token Binding - Research

Researched: 2026-06-06 Domain: JWT access token fingerprinting via HMAC-SHA256 claim Confidence: HIGH


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Use settings.secret_key (SECRET_KEY env var) as the HMAC key — no new env var.
  • D-02: Missing User-Agent or Accept-Language falls back to "". The fgp claim is always computed and always validated — no skip path.
  • D-03: Fingerprint mismatch raises HTTP 401 immediately with detail="Token fingerprint mismatch". No soft mode.
  • D-04: Helper _compute_fgp(user_agent: str, accept_lang: str) -> str defined in backend/services/auth.py (module-private). Returns hmac.new(settings.secret_key.encode(), (user_agent + accept_lang).encode(), sha256).hexdigest()[:16].
  • D-05: create_access_token(user_id, role) gains user_agent: str = "" and accept_lang: str = "" parameters. Default empty strings preserve backward compatibility with existing tests.
  • D-06: fgp validation in get_current_user placed after the user_nbf block. If fgp_claim is empty (old token without claim) → allow (graceful migration). If non-empty and mismatch → 401.

Claude's Discretion

None stated.

Deferred Ideas (OUT OF SCOPE)

  • Key rotation process for SECRET_KEY
  • Per-request fingerprint rotation / device key pinning </user_constraints>

Summary

Phase 7.4 is a pure backend code change with no schema migrations, no new endpoints, and no frontend work. The change set is small and surgically scoped to three files:

  1. backend/services/auth.py — add _compute_fgp helper, extend create_access_token signature, embed fgp claim in payload.
  2. backend/deps/auth.py — add fgp validation block after the user_nbf check.
  3. backend/api/auth.py — pass request headers to create_access_token at the two call sites (login and refresh handlers).
  4. backend/tests/test_auth_fgp.py — new test file with four test cases following Phase 7.2/7.3 patterns.

No new dependencies. No new environment variables. hashlib is already imported in services/auth.py (line 21). hmac is already imported (line 22). request.headers is already accessible in both call sites and in get_current_user. The conftest _patch_es256_test_keys autouse fixture already covers all tests.

Primary recommendation: Three targeted edits to existing files plus one new test file — total diff should be under 60 lines of production code.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
fgp claim creation API / Backend (service layer) _compute_fgp in services/auth.py; pure Python, no FastAPI coupling
fgp claim embedding API / Backend (service layer) create_access_token builds the JWT payload
fgp claim validation API / Backend (dependency layer) get_current_user in deps/auth.py validates on every authenticated request
Header extraction API / Backend (router/dep layer) request.headers.get(...) idiomatic FastAPI pattern; already in both callers
HMAC key storage Config / env settings.secret_key — no new field needed

Standard Stack

No new packages. All required modules are already installed and imported.

Already Available

Module Location Already Imported? Notes
hmac stdlib Yes — services/auth.py:22 [VERIFIED: read file] Use hmac.new(...) (Python stdlib name is hmac.new)
hashlib stdlib Yes — services/auth.py:21 [VERIFIED: read file] hashlib.sha256 used as digestmod
hmac.compare_digest stdlib Already used — services/auth.py:419 [VERIFIED: read file] Same pattern as backup code comparison
request.headers FastAPI Request request: Request already in get_current_user signature [VERIFIED: read file] Pattern: request.headers.get("User-Agent", "")

Installation

No new packages to install.


Package Legitimacy Audit

Not applicable — no new packages.


Architecture Patterns

System Architecture Diagram

POST /api/auth/login              POST /api/auth/refresh
        |                                  |
        | request.headers.get(...)         | request.headers.get(...)
        v                                  v
services/auth.create_access_token(user_id, role, user_agent, accept_lang)
        |
        | _compute_fgp(user_agent, accept_lang) -> hexdigest[:16]
        | payload["fgp"] = fgp_value
        | jwt.encode(payload, private_pem, algorithm="ES256")
        v
     JWT access token (fgp claim embedded)

Every authenticated request:
        |
        v
deps/auth.get_current_user(request, credentials, session)
        |
        | decode_access_token -> payload
        | user_nbf check (Phase 7.2 block, lines 6285)
        |
        | [NEW] fgp_claim = payload.get("fgp", "")
        | if fgp_claim:
        |     fgp_actual = _compute_fgp(request.headers.get(...), request.headers.get(...))
        |     if not hmac.compare_digest(fgp_claim, fgp_actual):
        |         raise HTTP 401 "Token fingerprint mismatch"
        |
        v
     uuid.UUID(payload["sub"]) -> User lookup

No new files in src/. One new test file:

backend/
├── services/auth.py          # add _compute_fgp + extend create_access_token
├── deps/auth.py              # add fgp validation block after user_nbf
├── api/auth.py               # update 2 call sites: login + refresh
└── tests/
    └── test_auth_fgp.py      # new file, 4 test cases

Pattern 1: fgp helper (_compute_fgp)

What: Module-private function in services/auth.py, placed before create_access_token. When to use: Called from create_access_token (at issuance) and referenced by get_current_user via import.

# Source: CONTEXT.md D-04
import hmac as _hmac_mod  # avoid shadowing module if 'hmac' name is also used as variable
import hashlib

def _compute_fgp(user_agent: str, accept_lang: str) -> str:
    """Return 16-char hex fingerprint of User-Agent + Accept-Language (D-04)."""
    return _hmac_mod.new(
        settings.secret_key.encode(),
        (user_agent + accept_lang).encode(),
        hashlib.sha256,
    ).hexdigest()[:16]

Note on import name: hmac is already imported at line 22 of services/auth.py as import hmac. The hmac module exposes .new() directly. No rename needed — hmac.new(...) works. [VERIFIED: stdlib docs, hmac module]

Pattern 2: create_access_token signature extension

What: Add two keyword parameters with empty-string defaults to create_access_token. Exact current signature (line 87): def create_access_token(user_id: str, role: str) -> str: [VERIFIED: read file]

Updated signature:

def create_access_token(
    user_id: str,
    role: str,
    user_agent: str = "",
    accept_lang: str = "",
) -> str:

Inside the function, add "fgp": _compute_fgp(user_agent, accept_lang) to the payload dict alongside existing claims (sub, role, typ, iat, exp, jti). [VERIFIED: payload dict at lines 93100 of services/auth.py]

Pattern 3: fgp validation block in get_current_user

Placement: After line 85 (end of user_nbf block), before line 87 (uuid.UUID(payload["sub"])). [VERIFIED: read deps/auth.py]

Structural model — copy the user_nbf block pattern (try / except HTTPException: raise / except Exception: log + fail-open), but with a critical difference from NBF: the fgp mismatch must NOT be swallowed by the broad except. The mismatch raise is an explicit HTTPException inside the try block, which the except HTTPException: raise guard re-raises correctly.

# ── fgp check (D-06, Phase 7.4) ────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────

Note: _compute_fgp is a module-private function in services/auth.py. deps/auth.py already imports from services import auth as auth_service (line 32). Calling auth_service._compute_fgp(...) follows the existing import pattern. Alternatively, the function can be made public (no underscore), but keeping it private signals it is not part of the public service API. Either approach is fine — the planner should decide. [ASSUMED: naming convention choice]

Pattern 4: Caller updates in api/auth.py

Login handler (line 290): [VERIFIED: read api/auth.py]

# Before:
access_token = auth_service.create_access_token(str(user.id), user.role)

# After:
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", ""),
)

Refresh handler (line 364): [VERIFIED: read api/auth.py]

# Before:
access_token = auth_service.create_access_token(user_id_str, user.role)

# After:
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", ""),
)

Both handlers already have request: Request in their signature. [VERIFIED: read api/auth.py lines 192195 and 321325]

Anti-Patterns to Avoid

  • Calling _compute_fgp in deps/auth.py directly instead of via auth_service: deps/auth.py already imports auth_service — use it for consistency. Do not add a second import of the function.
  • Wrapping the fgp HTTPException raise in a broad except: The except HTTPException: raise guard at line 81 of deps/auth.py ensures the intentional 401 is never swallowed. The fgp check must be structured inside the same or a separate try block with the same guard. [VERIFIED: read deps/auth.py lines 8184]
  • Treating fgp_claim == "" as a mismatch: Empty fgp_claim means the token predates this phase — it must be allowed (graceful migration, D-06).
  • Using == instead of hmac.compare_digest: Constant-time comparison is mandatory for all token comparisons (SEC-06, CLAUDE.md).

Don't Hand-Roll

Problem Don't Build Use Instead Why
Constant-time string comparison Custom loop hmac.compare_digest SEC-06 — timing attack prevention; already established in codebase
HMAC computation Manual SHA-256 + XOR hmac.new(..., hashlib.sha256) Correct keyed-HMAC semantics; raw SHA-256 is not an HMAC

Common Pitfalls

Pitfall 1: fgp mismatch swallowed by broad except

What goes wrong: The fgp HTTPException is raised inside the try block but the broad except Exception catches it before except HTTPException: raise runs — only if the guard order is reversed. Why it happens: Python evaluates except clauses in order; if except Exception appears before except HTTPException, the HTTPException is matched and swallowed. How to avoid: The existing pattern in deps/auth.py already has except HTTPException: raise before except Exception (lines 8184). Maintain this order. [VERIFIED: read file] Warning signs: fgp mismatch returns 200 or 500 instead of 401.

Pitfall 2: test fixtures call create_access_token without new params — will fgp validation break?

What goes wrong: After Phase 7.4, all test-issued tokens will carry fgp computed from ("", "") because user_agent="" and accept_lang="" are the defaults. Test requests sent without User-Agent/Accept-Language headers will also compute _compute_fgp("", ""). These will match — no test breakage. Why it happens: Default parameters make the no-arg call create_access_token(user_id, role) emit fgp = hmac("" + "")[:16]. An unauthenticated test request with no headers also computes the same value. Match succeeds. How to avoid: No action required for existing tests. The test for "wrong fgp" must explicitly issue a token with headers A then make a request with different headers B. Warning signs: All existing auth tests still pass (expected); they should not require changes.

Pitfall 3: hmac.new vs hmac.HMAC

What goes wrong: hmac.new(...) is the correct stdlib constructor. Attempting hmac.HMAC(...) directly is not the intended public API. How to avoid: Use hmac.new(key, msg, digestmod) exactly as specified in D-04. Warning signs: AttributeError: module 'hmac' has no attribute 'HMAC' (HMAC is the class, new() is the factory).

Pitfall 4: _compute_fgp visibility across module boundary

What goes wrong: deps/auth.py calls auth_service._compute_fgp(...). Python name mangling does NOT apply to module-level _ names — they are accessible from other modules, just conventionally private. This will work fine. How to avoid: No special handling needed; the underscore is a convention, not an enforcement. [VERIFIED: Python docs]

Pitfall 5: conftest fixtures will produce fgp-bearing tokens after Phase 7.4

What goes wrong: After the change, create_access_token(str(user_id), "user") in conftest (and all test files) will embed fgp = hmac("", "")[:16]. Requests made by auth_client or async_client without User-Agent/Accept-Language headers compute the same fingerprint → 200. This is the intended behaviour. Warning signs: If a test explicitly sets User-Agent in its request headers, the token issued by the fixture (with user_agent="") will mismatch the request headers → 401. This would only affect tests that set custom User-Agent headers, which currently none do. [VERIFIED: grep of conftest and test_auth_deps.py]


Code Examples

Complete fgp Helper (from CONTEXT.md D-04)

# Source: CONTEXT.md D-04 (canonical)
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
    """Return 16-char hex fingerprint binding a token to its client context (D-04)."""
    return hmac.new(
        settings.secret_key.encode(),
        (user_agent + accept_lang).encode(),
        hashlib.sha256,
    ).hexdigest()[:16]

Test Case Structure (4 required by CONTEXT.md)

# Source: CONTEXT.md §Specific Ideas

# Test 1 — correct fgp → 200
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
    "/test/me",
    headers={
        "Authorization": f"Bearer {token}",
        "User-Agent": "Mozilla/5.0",
        "Accept-Language": "en",
    },
)
assert resp.status_code == 200

# Test 2 — wrong fgp → 401
token = create_access_token(user_id, "user", user_agent="Mozilla/5.0", accept_lang="en")
resp = await auth_client.get(
    "/test/me",
    headers={
        "Authorization": f"Bearer {token}",
        "User-Agent": "different-agent",  # mismatch
        "Accept-Language": "en",
    },
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Token fingerprint mismatch"

# Test 3 — token without fgp claim → 200 (migration grace)
# Manually craft a token without "fgp" in the payload
payload_no_fgp = {
    "sub": str(user_id), "role": "user", "typ": "access",
    "iat": ..., "exp": ..., "jti": str(uuid.uuid4()),
}
token_no_fgp = jwt.encode(payload_no_fgp, private_pem, algorithm="ES256")
resp = await auth_client.get("/test/me", headers={"Authorization": f"Bearer {token_no_fgp}"})
assert resp.status_code == 200

# Test 4 — missing User-Agent → empty-string binding works
token = create_access_token(user_id, "user")  # user_agent="", accept_lang=""
resp = await auth_client.get(
    "/test/me",
    headers={"Authorization": f"Bearer {token}"},
    # No User-Agent, no Accept-Language — both default to ""
)
assert resp.status_code == 200

State of the Art

Old Approach Current Approach When Changed Impact
Access tokens had no device binding Tokens carry fgp HMAC claim Phase 7.4 Stolen tokens can only be replayed from same UA/language context

Detailed File Change Map

File Change Lines affected (approx)
backend/services/auth.py Add _compute_fgp helper (6 lines) before create_access_token; extend create_access_token signature (+2 params); add "fgp" to payload dict (+1 line) ~10 lines added
backend/deps/auth.py Add fgp validation block after line 85 ~9 lines added
backend/api/auth.py Update login call site (line 290) and refresh call site (line 364) to pass headers ~8 lines changed
backend/tests/test_auth_fgp.py New file: 4 test functions + 1 helper fixture ~100 lines new

Total production code change: ~27 lines. Well within the ≤50 line bug-fix ceiling for any single plan.


Validation Architecture

Test Framework

Property Value
Framework pytest + pytest-asyncio
Config file backend/pytest.ini (or pyproject.toml)
Quick run command pytest tests/test_auth_fgp.py -v
Full suite command pytest -v

Phase Requirements → Test Map

Behaviour Test ID Test Type Automated Command
Token with correct fgp → 200 FGP-01 integration pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x
Token with wrong fgp → 401 FGP-02 integration pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x
Token without fgp claim → 200 (migration grace) FGP-03 integration pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x
Missing headers → empty-string binding works FGP-04 integration pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x

Sampling Rate

  • Per task commit: pytest tests/test_auth_fgp.py tests/test_auth_deps.py -x
  • Per wave merge: pytest -v (all 411+ tests)
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • tests/test_auth_fgp.py — new file, covers FGP-01..04. Must be created in Wave 0 as xfail stubs before implementation.

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes hmac.compare_digest for all fingerprint comparison
V3 Session Management yes fgp binds access token to originating client context
V5 Input Validation yes Headers extracted via .get(..., "") — no injection possible; HMAC treats as opaque bytes
V6 Cryptography yes hmac.new(..., hashlib.sha256) — standard keyed HMAC, not custom

Known Threat Patterns

Pattern STRIDE Standard Mitigation
Stolen access token replay from different device Spoofing / Elevation fgp mismatch → HTTP 401
Timing attack on fingerprint comparison Information Disclosure hmac.compare_digest (constant-time)
Empty-string collision (all CLI clients share one fingerprint) Spoofing Accepted by D-02 — deliberate design; equivalent to pre-Phase-7.4 security for CLI clients

Open Questions (RESOLVED)

  1. _compute_fgp visibility: private (_compute_fgp) vs public (compute_fgp)?

    • What we know: deps/auth.py needs to call it; it is currently planned as _compute_fgp (module-private by convention).
    • What's unclear: The CONTEXT.md specifies _compute_fgp. Calling auth_service._compute_fgp from deps/auth.py works but is unconventional.
    • Recommendation: Keep the underscore per D-04; auth_service._compute_fgp is acceptable in this tightly coupled internal boundary. Alternatively, expose as compute_fgp (no underscore) for cleaner inter-module use — either is consistent with the codebase. The planner should pick one and document it. [ASSUMED: naming decision]
    • RESOLVED: Keep private (_compute_fgp) per D-04. The underscore convention signals it is not part of the public service API. auth_service._compute_fgp(...) in deps/auth.py is acceptable at this tightly coupled internal boundary.
  2. Should the fgp check be inside the existing user_nbf try block or in its own try block?

    • What we know: The user_nbf block is a try/except with fail-open on Redis errors (D-04 of Phase 7.2). The fgp check has no external I/O — it cannot fail due to infrastructure.
    • What's unclear: Structurally, putting fgp inside the same try block would work, but it conflates two different concerns. A separate, unconditional if-block (no try/except needed) is cleaner.
    • Recommendation: Place fgp as a plain if fgp_claim: block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper needed for fgp — it is pure computation, not I/O. [ASSUMED]
    • RESOLVED: Place as a standalone if fgp_claim: block AFTER the user_nbf try/except ends (after line 85). No try/except wrapper — pure computation, no I/O. The existing except HTTPException: raise guard in any wrapping try block would still catch the mismatch 401 correctly, but keeping it outside is cleaner.

Environment Availability

Step 2.6: SKIPPED — this phase is a pure backend code change with no external dependencies beyond the existing Python stdlib and already-installed PyJWT. No new tools, services, or runtimes required.


Assumptions Log

# Claim Section Risk if Wrong
A1 _compute_fgp naming convention (underscore vs public) Open Questions #1 Cosmetic only — either works at runtime
A2 fgp check is a plain if-block, not wrapped in try/except Open Questions #2 Cosmetic only — try/except around pure computation is harmless but unnecessary

Sources

Primary (HIGH confidence)

  • backend/services/auth.py — verified exact current signatures, imports, payload structure, existing hmac.compare_digest usage [VERIFIED: read file]
  • backend/deps/auth.py — verified get_current_user structure, user_nbf block lines 6285, request: Request in signature, except HTTPException: raise guard [VERIFIED: read file]
  • backend/api/auth.py — verified login call site (line 290), refresh call site (line 364), both have request: Request [VERIFIED: read file]
  • backend/config.py — verified secret_key: str = "CHANGEME" at line 31 [VERIFIED: read file]
  • backend/tests/conftest.py — verified autouse _patch_es256_test_keys fixture, auth_user/admin_user fixtures call create_access_token(str(user_id), role) [VERIFIED: read file]
  • backend/tests/test_auth_deps.py — verified Phase 7.2 NBF test pattern (FakeRedis, make_test_app, _create_user helper) [VERIFIED: read file]
  • backend/tests/test_auth_es256.py — verified Phase 7.3 test pattern (autouse es256_keys fixture, test naming convention) [VERIFIED: read file]
  • CONTEXT.md — locked decisions D-01 through D-06 [VERIFIED: read file]

Secondary (MEDIUM confidence)

  • grep -rn "create_access_token" output — confirmed only two production call sites exist (api/auth.py:290 login and api/auth.py:364 refresh); all other hits are test files [VERIFIED: bash grep]
  • Python stdlib hmac module — hmac.new(key, msg, digestmod) is the correct constructor [CITED: docs.python.org/3/library/hmac.html]

Metadata

Confidence breakdown:

  • File change map: HIGH — read all target files directly
  • Standard stack: HIGH — no new packages; all modules verified present
  • Test patterns: HIGH — read Phase 7.2/7.3 test files directly
  • Architecture: HIGH — CONTEXT.md fully specifies implementation
  • Pitfalls: HIGH — derived from direct code inspection

Research date: 2026-06-06 Valid until: N/A — this is a single-session phase with no external dependencies