Skip to content

15.10 security: migrate session JWTs off python-jose to PyJWT, fix Bandit B324 - #39

Merged
tetakta merged 1 commit into
mainfrom
session/15.10-jose-migration
Oct 16, 2026
Merged

tetakta merged 1 commit into
mainfrom
session/15.10-jose-migration

Conversation

@tetakta

@tetakta tetakta commented Oct 15, 2026

Copy link
Copy Markdown
Member

Why

pip-audit and bandit have been red on main since at least 2026-10-12 — before
the 1.32 quota work. Neither is a required check, so every merge since has
sailed past them. One of the two was flagging an unpatched dependency on the
authentication path.

1. Are the advisories actually reachable in our usage?

No — neither is. Both still had to go, because neither has a fix version and
python-jose is what signs and verifies our session JWTs.

PYSEC-2026-4298 (= CVE-2026-85394, incomplete fix of CVE-2024-33663),
python-jose ≤ 3.5.0, CVSS 7.5 — "algorithm confusion guard bypassed by
DER-encoded public keys"
. DER-encoded public keys without PEM/SSH framing are
accepted as HMAC secrets, so an attacker who holds the service's public key
can forge HS256 tokens — and, per the advisory, only "when algorithm
restrictions are not explicitly configured"
.

Our usage fails both preconditions:

Precondition for the attack app/core/auth.py before this PR
service verifies with an asymmetric key HS256 over the symmetric settings.secret_key — we have no keypair, so there is no public key to repurpose as an HMAC secret
decode called without restricting algorithms algorithms=[ALGORITHM], ALGORITHM = "HS256", hardcoded — never read from the token header

PYSEC-2026-1325 (= CVE-2024-23342, GHSA-wj6h-64fc-37mp), ecdsa ≤ 0.19.2,
CVSS High — the Minerva timing attack on P-256. It affects ECDSA signing,
keygen and ECDH via ecdsa.SigningKey.sign_digest(); verification is
unaffected, and upstream considers side channels out of scope (no fix planned).

Not reachable either: ecdsa was only ever present as a transitive dep of
python-jose, and nothing in the tree imports it —
grep -rn "import ecdsa\|from ecdsa\|SigningKey\|sign_digest" --include=*.py
returns nothing. All our real P-256 work (services/c2pa.py,
services/device_signature.py, generate_certs.py) goes through
cryptography's OpenSSL backend, which is not the vulnerable implementation.

Because neither is reachable, no new S- row in docs/security.md — per the
task's own condition. This is a dependency-hygiene fix on the auth path, not a
live exposure.

2. The migration — sessions survive, byte-for-byte

python-jose → PyJWT in app/core/auth.py. Token format is not merely
compatible, it is identical: encoding the same payload with jose 3.5.0 (the
version on prod) and PyJWT 2.15.1 produces the same string, byte for byte —
same {"alg":"HS256","typ":"JWT"} header, same claim serialisation, same
signature.

jose  token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZjBkN2Ux…
pyjwt token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZjBkN2Ux…
byte-identical: True

Cross-verified both directions (PyJWT decodes jose-issued tokens and vice
versa), plus expired → ExpiredSignatureError, wrong secret →
InvalidSignatureError, alg=none → InvalidAlgorithmError. So no session
is logged out by this deploy
— there is no observable difference between a
token minted before it and one minted after.

Other deliberate choices:

  • decode_token still raises ValueError("Invalid token"), so
    app/api/deps.py needs no change (it catches (ValueError, KeyError)).
  • Decode now also requires exp and sub. Every token we mint has both, so
    this costs nothing in compatibility — but a correctly signed token that omits
    exp is now refused rather than valid forever.
  • cryptography becomes a declared dependency. It was only reaching us
    transitively via python-jose[cryptography], while three modules import it
    directly (services/c2pa.py, services/device_signature.py,
    core/crypto.py). Dropping python-jose without this would have left a
    directly-imported package undeclared.
  • Removing python-jose removes ecdsa too — confirmed by installing the whole
    dependency set from scratch into a clean venv: ecdsa does not come back, so
    pip-audit's two packages both disappear.
  • tests/test_auth_token.py (9 tests) locks the shape in: round-trip keeps
    sub/ver, the header really is HS256, and expired / wrong-secret /
    alg=none / missing-exp / malformed tokens all raise ValueError.

⚠️ deploy.yml had to change, and this is the part worth a second pair of eyes

The deploy does not run pip install . — it installs an explicit list:

pip install -q email-validator pgvector beautifulsoup4

I checked the prod venv before writing this: python-jose 3.5.0, ecdsa 0.19.2,
cryptography 49.0.0, and no PyJWT. So merging the code change alone would
have made tetapi-api fail to import on restart. PyJWT is now named in that
line, and the replaced python-jose/ecdsa are uninstalled from the prod venv
so it matches the manifest. Flagging explicitly per CLAUDE.md, since this
touches how production gets its packages. (cryptography 49.0.0 already
satisfies the new >=44.0.0 pin, so nothing moves there.)

3. Bandit B324 — one line

hashlib.md5 at app/api/routes/badge.py:79 is the badge ETag cache key, not
a security primitive, so the fix is usedforsecurity=False. Changing the digest
would invalidate every cached badge ETag for no benefit.

4. Why nothing caught this — one owner setting

bandit and pip-audit triggered on push: [main] only. A red result was
therefore never visible on the PR that caused it, and they could not be made
required checks at all
, since a required check has to run on the pull request.
This PR adds a pull_request: trigger to both.

That leaves the actual gate as an owner action: make pytest, bandit and
pip-audit required status checks on main (Settings → Branches → branch
protection). They caught a real unpatched dependency on the authentication path
and nothing enforced them — that is the whole reason this sat red for days.

Verification

  • pytest -q → 120 passed (111 existing + 9 new)
  • bandit -r app -ll → 0 high, 0 medium, exit 0 (was 1 high)
  • pip-audit → no python-jose, no ecdsa in the resolved set
  • ruff --select E,F,I,UP → identical findings to main, i.e. no new lint debt
  • prod checks after deploy: reported in a comment on this PR

🤖 Generated with Claude Code

pip-audit and bandit have been red on main since at least 2026-10-12. Neither
is a required check, so merges kept sailing past them.

pip-audit: python-jose 3.5.0 (PYSEC-2026-4298) and its ecdsa 0.19.2 dep
(PYSEC-2026-1325), both with no fix version, so pinning forward was not an
option. Neither is reachable in our usage — see the PR for the analysis — but
python-jose signs and verifies our session JWTs, so an unpatchable advisory
sitting on the authentication path is not something to carry.

core/auth.py now uses PyJWT. The token stays byte-identical for our payload
(verified against jose 3.5.0: same header, same claims, same signature), so
sessions issued before the deploy stay valid. decode_token still raises
ValueError, so api/deps.py is unchanged. Decode now also requires exp+sub, so
a correctly signed token that omits exp is refused instead of never expiring.

cryptography moves from transitive (via python-jose[cryptography]) to a
declared dependency — services/c2pa.py, services/device_signature.py and
core/crypto.py import it directly, and dropping python-jose would otherwise
have undeclared it. Removing python-jose also removes ecdsa: nothing else in
the tree pulls it, and no code imports it (all P-256 work goes through
cryptography's OpenSSL backend, not the pure-Python ecdsa package).

deploy.yml installs an explicit package list rather than `pip install .`, so
PyJWT is named there too — without it the service would fail to import on
restart — and the replaced python-jose/ecdsa are uninstalled from the prod
venv.

bandit B324: hashlib.md5 for the badge ETag is a cache key, not a security
primitive, so usedforsecurity=False is the fix; changing the digest would
invalidate every cached badge ETag for nothing.

bandit and pip-audit also gain a pull_request trigger: they ran on main only,
so a red result was never visible on the PR that caused it and could not be
made a required check at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tetakta
tetakta merged commit 9a838e0 into main Oct 16, 2026
3 checks passed
@tetakta
tetakta deleted the session/15.10-jose-migration branch October 16, 2026 09:31
@tetakta

tetakta commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Prod verification after deploy

Deploy run green on main@9a838e0, and all five checks on the merge commit pass —
including the two that had been red: bandit ✅, pip-audit ✅, pytest ✅, CodeQL ✅,
deploy ✅.

Checked live rather than trusted from CI:

Check Result
prod venv PyJWT 2.15.1, cryptography 49.0.0 — python-jose and ecdsa gone
services tetapi-api, tetapi-celery-worker, tetapi-celery-beat all active
/auth/me via pk_live_ key 200, correct account — API-key branch unaffected
/auth/me, malformed JWT 401 {"detail":"Invalid token"} — PyJWT decode path live, no 500
/auth/me, alg=none JWT 401 — the algorithm-confusion class the advisory was about
/auth/token, wrong password 401 {"detail":"Invalid credentials"} — mint path imports and runs
badge ETag (the B324 line) 32-hex md5 ETag served, revalidate with If-None-Match → 304
/health, /search, /businesses 200
api + celery journals since deploy 0 errors, 0 tracebacks, no jose/jwt import noise

The prod SECRET_KEY is also ≥32 bytes — PyJWT warns on short HMAC keys (RFC 7518 §3.2)
and no such warning appears in the journal.

Two paths left to the owner, deliberately

/auth/token password sign-in and the magic-link flow are not reachable from this
session: one needs the account password, the other a mailbox. The only mint path open
to me was POST /auth/logout-all (it authenticates with the pk_live_ key), and that
bumps token_version — it would have signed the owner out of app.tetapi.dev to
produce a test token. Asked rather than did it; the owner chose to run these two
themselves:

# 1. password sign-in -> JWT, then that JWT against /auth/me
TOKEN=$(curl -s -X POST https://api.tetapi.dev/api/v1/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"email":"YOUR_EMAIL","password":"YOUR_PASSWORD"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
curl -s -o /dev/null -w 'auth/me -> HTTP %{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" https://api.tetapi.dev/api/v1/auth/me
# 2. magic link / email code -> the emailed 6-digit code -> JWT
curl -s -X POST https://api.tetapi.dev/api/v1/auth/email-code \
  -H 'Content-Type: application/json' -d '{"email":"YOUR_EMAIL"}'
curl -s -X POST https://api.tetapi.dev/api/v1/auth/verify-code \
  -H 'Content-Type: application/json' \
  -d '{"email":"YOUR_EMAIL","code":"THE_6_DIGITS"}'

Both should return a token and a 200. Worth noting what they cannot reveal: since
jose 3.5.0 and PyJWT emit byte-identical tokens for this payload, a token minted
before the deploy and one minted after are the same bytes — so there is no
"old token" case left to fail.

Still open — one owner setting

Make pytest, bandit and pip-audit required status checks on main
(Settings → Branches → branch protection). The pull_request: triggers in this PR are
the prerequisite — you can see them working above, both reported on this PR — but the
checks remain advisory until they are required. They caught a real unpatched dependency
on the authentication path and nothing enforced them.

Docs: infra #152 (merged) — changelog,
roadmap 15.10, and security.md corrections. No new S-* row, since neither advisory
turned out reachable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant