Skip to content

cowork: fail-closed expiry verification + rotation type guards - #24

Open
Coding-Dev-Tools wants to merge 4 commits into
masterfrom
cowork/improve-apiauth
Open

cowork: fail-closed expiry verification + rotation type guards#24
Coding-Dev-Tools wants to merge 4 commits into
masterfrom
cowork/improve-apiauth

Conversation

@Coding-Dev-Tools

Copy link
Copy Markdown
Owner

Two security/correctness fixes for the verification and rotation paths:

  1. Fail-closed expiry verification — verify_api_key()/verify_jwt_token() compared parsed expires_at inside an except-and-pass block, so naive/malformed/non-string timestamps silently reported an expired or tampered key as VALID. A shared _parse_expiry() normalizes naive timestamps to UTC; verifiers now return status='invalid' for a present-but-unparseable expires_at. Key-hash matching hardened with hmac.compare_digest.

  2. Rotation type guards — rotate_key() on a JWT entry (or rotate_jwt() on an API-key entry) silently corrupted the keystore entry (stale signing_secret_hash, bogus key_hash, bumped version). Both now raise ValueError on mismatched type and leave the entry untouched.

Adds tests/test_verify_expiry_failclosed.py (7) and tests/test_rotate_type_guard.py (3). Full suite: 79 passed, ruff clean.

cowork-bot and others added 3 commits July 11, 2026 18:49
…jwt_token

A present-but-unparseable expires_at (naive timestamp, malformed string, or
non-string value) previously hit an 'except (ValueError, TypeError): pass' and
fell through to status='valid' -- a silent fail-open that let a corrupted,
naive, or tampered expiry bypass expiry entirely in a security library.

- Add _parse_expiry(): returns an aware UTC datetime, normalizing naive values
  to UTC so comparisons never raise; returns None only when truly unparseable.
- verify_api_key/verify_jwt_token now fail CLOSED: a set-but-unparseable
  expires_at yields status='invalid' instead of 'valid'.
- Harden the key-hash match with hmac.compare_digest (constant-time) and skip
  entries whose stored hash is missing/non-string.
- +7 regression tests (tests/test_verify_expiry_failclosed.py) covering naive
  past/future, malformed, and non-string expiries for keys and JWTs.

check_expiry (display helper) behavior is unchanged (tests lock in None-on-
unparseable). 76 tests pass; changed files ruff-clean.
…oss-type keystore corruption

Calling the API-key rotation path on a JWT entry (or vice versa) silently
corrupted the entry: key_hash/prefix overwritten, signing_secret_hash left
stale, version bumped — a half-migrated entry neither verifier trusts.
Both rotators now raise ValueError on a mismatched entry type, leaving the
entry untouched; correct-type rotation behavior is unchanged. Adds
tests/test_rotate_type_guard.py (3 tests). Full suite: 79 passed, ruff clean.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

🤖 Automated Code Review

⚠️ Ruff Lint — 2 issue(s)

::error title=ruff (SIM105),file=/home/runner/work/apiauth/apiauth/src/apiauth/keystore.py,line=103,endLine=106::src/apiauth/keystore.py:103:13: SIM105 Use `contextlib.suppress(OSError)` instead of `try`-`except`-`pass`%0A  help: Replace `try`-`except`-`pass` with `with contextlib.suppress(OSError): ...`
::error title=ruff (I001),file=/home/runner/work/apiauth/apiauth/tests/test_keystore_atomic.py,line=2,endLine=6::tests/test_keystore_atomic.py:2:1: I001 Import block is un-sorted or un-formatted%0A  help: Organize imports

⚠️ Ruff Format — Formatting needed

unformatted: File would be reformatted
 --> conftest.py:2:1
  |
1 | """pytest configuration — add project src to Python path and skip rate limits."""
2 +
3 | import os
  |

unformatted: File would be reformatted
   --> src/apiauth/cli.py:28:1
    |
27  | except ImportError:
28  +
29  |     def require_license(tool):
30  |         def decorator(func):
31  |             return func
32  +
33  |         return decorator
34  |
35  +
36  | console = Console()
--------------------------------------------------------------------------------
344 |     if expiry_days:
    -         expiry = (
    -             dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expiry_days)
    -         ).isoformat()[:23] + "Z"
345 +         expiry = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expiry_days)).isoformat()[:23] + "Z"
346 |
--------------------------------------------------------------------------------
439 |         prefix = _make_env_prefix(k)

✅ Secret Detection — Clean

✅ Large Files — Within limits

📊 Diff Stats — 4 file(s) changed

 src/apiauth/keygen.py           | 14 ++++++++++++++
 src/apiauth/keystore.py         | 41 ++++++++++++++++++++++++++++++++++++---
 tests/test_keystore_atomic.py   | 43 +++++++++++++++++++++++++++++++++++++++++
 tests/test_rotate_type_guard.py | 39 +++++++++++++++++++++++++++++++++++++
 4 files changed, 134 insertions(+), 3 deletions(-)

Verdict: ⚠️ Warnings — Lint/format issues found. Recommend fixing before merge.

Automated by Coding-Dev-Tools/.github reusable workflow.

- _save() now writes to a temp file in the key dir, fsyncs, and os.replace()s
  into keys.json. Previously an in-place torn write would brick the whole
  keystore (decrypt-failure -> RuntimeError on every later load).
- _get_or_create_master_key() validates a 32-byte master.key; a truncated or
  corrupt key now fails loudly instead of raising an opaque crypto error or
  being silently regenerated (which would make all entries undecryptable).
- tests/test_keystore_atomic.py: 3 tests covering reloadability, no temp-file
  leftovers, torn-store refusal to overwrite, corrupt-master-key guard.
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.

3 participants