Compare commits
4
Commits
3414571091
...
fd05563ec6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd05563ec6 | ||
|
|
c77b97b6d3 | ||
|
|
de622e8004 | ||
|
|
52c56b5416 |
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: "07.4"
|
||||
slug: security-token-fingerprinting-token-binding-inserted
|
||||
status: verified
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: 2026-06-06
|
||||
---
|
||||
|
||||
# Phase 07.4 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| client → API (login/refresh) | Client `User-Agent` and `Accept-Language` headers are untrusted; HMAC treats them as opaque bytes — no injection vector | Header strings → HMAC-SHA256 digest (hex) |
|
||||
| JWT payload → get_current_user | `fgp` claim extracted from decoded (ES256 signature-verified) JWT; attacker cannot forge without ES256 private key | fgp hex string (16 chars) |
|
||||
| fgp comparison | Fixed-length (16-char hex) strings compared with `hmac.compare_digest` | Constant-time boolean |
|
||||
| test harness → test DB | xfail stubs (Plan 01) create no DB state; no trust boundary crossed | None |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|-------------|------------|--------|
|
||||
| T-07.4-01 | Spoofing | Access token replay from different device | mitigate | `fgp` mismatch → HTTP 401 in `deps/auth.py:98-101`; attacker must match UA + Accept-Language from original client context | closed |
|
||||
| T-07.4-02 | Information Disclosure | Timing attack on fingerprint comparison | mitigate | `hmac.compare_digest` (constant-time) used at `deps/auth.py:98`; satisfies SEC-06 requirement | closed |
|
||||
| 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 level for those clients | closed |
|
||||
| T-07.4-04 | Tampering | fgp HTTPException swallowed by broad `except` | mitigate | fgp validation block placed **outside** try/except in `deps/auth.py:88-104` (pure computation, no I/O); HTTP 401 always propagates | closed |
|
||||
| T-07.4-SC | Tampering | Supply-chain via npm/pip/cargo installs | accept | No new third-party packages installed in either plan; only stdlib `hmac` and `hashlib` used | closed |
|
||||
|
||||
*Status: open · closed*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-07.4-01 | T-07.4-03 | CLI clients (no User-Agent or Accept-Language) all share `fgp("")` — they are internally consistent but one CLI session token cannot distinguish another CLI session. Design decision D-02 documents this as acceptable: CLI use is already authenticated via the same token family; the fingerprint adds value for browser clients where header variance is meaningful. | project owner (gsd workflow) | 2026-06-06 |
|
||||
| AR-07.4-02 | T-07.4-SC | Plans 01 and 02 introduce zero new dependencies; stdlib `hmac`/`hashlib` only. Supply-chain risk is identical to baseline. | project owner (gsd workflow) | 2026-06-06 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Evidence
|
||||
|
||||
| File | Evidence |
|
||||
|------|----------|
|
||||
| `backend/services/auth.py:87-88` | `_compute_fgp(user_agent, accept_lang)` — HMAC-SHA256 producing 16-char hex digest |
|
||||
| `backend/services/auth.py:115` | `"fgp": _compute_fgp(user_agent, accept_lang)` embedded in access token payload |
|
||||
| `backend/api/auth.py:293, 372` | Login and refresh both pass `user_agent=request.headers.get("User-Agent", "")` |
|
||||
| `backend/deps/auth.py:88-104` | fgp validation block — **outside** try/except, `hmac.compare_digest` comparison, `detail="Token fingerprint mismatch"` on mismatch |
|
||||
| `backend/tests/test_auth_fgp.py` | 4 FGP tests (FGP-01..04) — all passing (promoted from xfail stubs in Plan 01) |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-06-06 | 5 | 5 | 0 | gsd-secure-phase (automated — short-circuit: register_authored_at_plan_time=true, threats_open=0) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** verified 2026-06-06
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
status: complete
|
||||
phase: 07.4-security-token-fingerprinting-token-binding-inserted
|
||||
source: [07.4-01-SUMMARY.md, 07.4-02-SUMMARY.md]
|
||||
started: 2026-06-06T21:00:00Z
|
||||
updated: 2026-06-06T21:10:00Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
[testing complete]
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Cold Start Smoke Test
|
||||
expected: Kill any running server/service. Start the application from scratch. Server boots without errors. Health check returns live response with all services healthy.
|
||||
result: pass
|
||||
notes: docker compose stop → rm -f → up -d; backend started clean, no errors in logs. GET /health returned {"status":"ok","checks":{"postgres":"ok","minio":"ok"}}
|
||||
|
||||
### 2. Login and normal API access still work
|
||||
expected: Log in with valid credentials. Access token returned with fgp claim embedded. Protected endpoint returns 200 — fgp embedding is transparent to the user.
|
||||
result: pass
|
||||
notes: Login returned ES256 JWT. Decoded payload confirmed fgp="9a27732e18cd110c" present. GET /api/documents with matching User-Agent returned HTTP 200.
|
||||
|
||||
### 3. Fingerprint mismatch is rejected
|
||||
expected: A token replayed from a different User-Agent should return HTTP 401 "Token fingerprint mismatch".
|
||||
result: pass
|
||||
notes: Token bound to Mozilla/5.0; replayed with "EvilBot/1.0 (stolen-token-replay)" and different Accept-Language. Response: {"detail":"Token fingerprint mismatch"} HTTP 401. Exact.
|
||||
|
||||
### 4. Refresh flow preserved
|
||||
expected: Refresh endpoint issues a new fgp-bound access token. New token works with same UA, fails with different UA.
|
||||
result: pass
|
||||
notes: Refresh returned new JWT with same fgp ("9a27732e18cd110c"). New token: 200 with matching UA, 401 with "AnotherBot/2.0". httpOnly refresh cookie correctly stored with #HttpOnly_ prefix in curl jar.
|
||||
|
||||
### 5. FGP test suite passes (5/5)
|
||||
expected: All FGP integration tests pass with no xfail stubs remaining. Full suite shows no new failures.
|
||||
result: issue
|
||||
reported: "4/4 tests passed initially. A concatenation collision vulnerability was found and fixed during UAT. A 5th regression test (FGP-05) was added. Full suite: 401 passed, 6 skipped, 7 xfailed, 1 failed (pre-existing docx ModuleNotFoundError). Final FGP suite: 5/5 passed."
|
||||
severity: major
|
||||
notes: Bug found and fixed in same session. See fix commit c77b97b.
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 4
|
||||
issues: 1
|
||||
pending: 0
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
|
||||
- truth: "_compute_fgp must not accept different splits of the same concatenated string as equivalent fingerprints"
|
||||
status: fixed
|
||||
reason: "User reported: token bound to UA='foobar'+AL='' was accepted by UA='foo'+AL='bar' — same HMAC input without separator"
|
||||
severity: major
|
||||
test: 5
|
||||
root_cause: "Line 91 in backend/services/auth.py: `(user_agent + accept_lang).encode()` — no separator between the two fields allows concatenation collision"
|
||||
artifacts:
|
||||
- path: "backend/services/auth.py:91"
|
||||
issue: "HMAC input lacks separator — user_agent + accept_lang ambiguous"
|
||||
missing:
|
||||
- "Null-byte separator between user_agent and accept_lang in HMAC input"
|
||||
- "Regression test FGP-05 to prevent reintroduction"
|
||||
fix_commit: "c77b97b"
|
||||
fix_status: "resolved — separator added, regression test added, all 5 FGP tests pass"
|
||||
+24
-11
@@ -1,10 +1,11 @@
|
||||
---
|
||||
phase: 7.4
|
||||
slug: 07.4-security-token-fingerprinting-token-binding-inserted
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
status: complete
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: 2026-06-06
|
||||
audited: 2026-06-06
|
||||
---
|
||||
|
||||
# Phase 7.4 — Validation Strategy
|
||||
@@ -38,13 +39,13 @@ created: 2026-06-06
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| FGP-01 | 01 | 0 | D-04/D-05 | T-7.4-01 | xfail stubs created for all 4 test cases | unit | `pytest tests/test_auth_fgp.py -v` | ❌ W0 | ⬜ pending |
|
||||
| FGP-02 | 01 | 1 | D-04 | T-7.4-01 | `_compute_fgp` helper returns 16-char hex HMAC | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
|
||||
| FGP-03 | 01 | 1 | D-05 | — | `create_access_token` embeds `fgp` claim | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
|
||||
| FGP-04 | 01 | 1 | D-06 | T-7.4-01 | Correct fgp → 200 | integration | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ⬜ pending |
|
||||
| FGP-05 | 01 | 1 | D-03/D-06 | T-7.4-01 | Wrong fgp → 401 "Token fingerprint mismatch" | integration | `pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x` | ✅ | ⬜ pending |
|
||||
| FGP-06 | 01 | 1 | D-06 | — | Token without fgp claim → 200 (migration grace) | integration | `pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x` | ✅ | ⬜ pending |
|
||||
| FGP-07 | 01 | 1 | D-02 | — | Missing headers → empty-string binding works | integration | `pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x` | ✅ | ⬜ pending |
|
||||
| FGP-01 | 01 | 0 | D-04/D-05 | T-7.4-01 | xfail stubs created for all 4 test cases | unit | `pytest tests/test_auth_fgp.py -v` | ✅ | ✅ green |
|
||||
| FGP-02 | 01 | 1 | D-04 | T-7.4-01 | `_compute_fgp` helper returns 16-char hex HMAC | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ✅ green |
|
||||
| FGP-03 | 01 | 1 | D-05 | — | `create_access_token` embeds `fgp` claim | unit | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ✅ green |
|
||||
| FGP-04 | 01 | 1 | D-06 | T-7.4-01 | Correct fgp → 200 | integration | `pytest tests/test_auth_fgp.py::test_fgp_match_returns_200 -x` | ✅ | ✅ green |
|
||||
| FGP-05 | 01 | 1 | D-03/D-06 | T-7.4-01 | Wrong fgp → 401 "Token fingerprint mismatch" | integration | `pytest tests/test_auth_fgp.py::test_fgp_mismatch_returns_401 -x` | ✅ | ✅ green |
|
||||
| FGP-06 | 01 | 1 | D-06 | — | Token without fgp claim → 200 (migration grace) | integration | `pytest tests/test_auth_fgp.py::test_no_fgp_claim_allowed -x` | ✅ | ✅ green |
|
||||
| FGP-07 | 01 | 1 | D-02 | — | Missing headers → empty-string binding works | integration | `pytest tests/test_auth_fgp.py::test_missing_headers_empty_string_binding -x` | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -52,7 +53,7 @@ created: 2026-06-06
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `tests/test_auth_fgp.py` — new file with 4 xfail stubs covering FGP-01..04 (correct match, wrong fgp, no fgp claim, missing headers)
|
||||
- [x] `tests/test_auth_fgp.py` — created (Wave 0 stubs) then promoted to 4 real assertions (Wave 1)
|
||||
|
||||
*Existing infrastructure covers all other requirements (conftest autouse fixtures, pytest-asyncio, FakeRedis).*
|
||||
|
||||
@@ -63,3 +64,15 @@ created: 2026-06-06
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Full suite regression check | All prior phases | Confirm 0 regressions from signature changes | Run `pytest -v` and verify count ≥ prior passing total |
|
||||
|
||||
---
|
||||
|
||||
## Validation Audit 2026-06-06
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Gaps found | 0 |
|
||||
| Resolved | 7 |
|
||||
| Escalated | 0 |
|
||||
|
||||
All 7 tasks confirmed COVERED. Ran `pytest tests/test_auth_fgp.py tests/test_auth_deps.py -v` — 14 passed, 0 failed. Full suite (404 passed, 4 skipped, 7 xfailed, 0 failed) confirmed in Plan 02 summary. No test generation needed — Wave 1 promoted all stubs to real assertions during execution.
|
||||
|
||||
@@ -88,7 +88,7 @@ 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(),
|
||||
(user_agent + "\x00" + accept_lang).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
@@ -164,6 +164,32 @@ async def test_no_fgp_claim_allowed(auth_client, db_session):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fgp_no_concatenation_collision(auth_client, db_session):
|
||||
"""FGP-05 regression: token bound to UA='foobar'+AL='' must not match UA='foo'+AL='bar'.
|
||||
|
||||
Without a separator in the HMAC input, both produce the same bytes ('foobar'),
|
||||
allowing a different header split to pass fingerprint validation.
|
||||
"""
|
||||
from services.auth import create_access_token
|
||||
|
||||
user = await _create_user(db_session, role="user")
|
||||
# Token issued for UA='foobar', no Accept-Language
|
||||
token = create_access_token(str(user.id), "user", user_agent="foobar", accept_lang="")
|
||||
|
||||
# Request arrives from a split: UA='foo', AL='bar' — same concatenation without sep
|
||||
resp = await auth_client.get(
|
||||
"/test/me",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "foo",
|
||||
"Accept-Language": "bar",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"] == "Token fingerprint mismatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_headers_empty_string_binding(auth_client, db_session):
|
||||
"""FGP-04: token issued with empty-string binding, request with empty UA → 200.
|
||||
|
||||
Reference in New Issue
Block a user