Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
persist-credentials: false

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

Expand Down Expand Up @@ -51,7 +51,7 @@ jobs:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

Expand All @@ -67,11 +67,12 @@ jobs:
run: pip-audit -r requirements.txt || pip-audit --desc

- name: Check for secrets
uses: trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6
uses: trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11 # v3.96.0
with:
path: ./
base: ""
head: ${{ github.sha }}
extra_args: --exclude-paths=.trufflehogignore

build:
runs-on: ubuntu-latest
Expand All @@ -82,7 +83,7 @@ jobs:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

Expand All @@ -38,4 +38,4 @@ jobs:
run: pip install twine && twine check dist/*

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ node_modules

# Operational state (not for commit)
LEARNING/
_cowork_ops/
_cowork_ops/
# Windows reserved device name artifact
nul
1 change: 1 addition & 0 deletions .trufflehogignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
^tests/.*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep test code in the secret scan

This pattern excludes the entire tests/ tree from the TruffleHog step configured in .github/workflows/ci.yml, so any genuine credential accidentally committed in a test or fixture will pass the security job undetected. Since the reported false positive is confined to one location in tests/test_keystore_atomic.py, narrow the suppression to that specific finding instead of disabling secret scanning for all test code.

Useful? React with 👍 / 👎.

1 change: 1 addition & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""pytest configuration — add project src to Python path and skip rate limits."""

import os
import sys
from pathlib import Path
Expand Down
23 changes: 11 additions & 12 deletions src/apiauth/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@
try:
from revenueholdings_license import require_license
except ImportError:

def require_license(tool):
def decorator(func):
return func

return decorator


console = Console()
err_console = Console(stderr=True)

Expand Down Expand Up @@ -339,9 +342,7 @@ def import_key(
now = _timestamp()
expiry = None
if expiry_days:
expiry = (
dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expiry_days)
).isoformat()[:23] + "Z"
expiry = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expiry_days)).isoformat()[:23] + "Z"

entry = {
"type": "api_key",
Expand Down Expand Up @@ -436,18 +437,18 @@ def _export_github_actions(active: list[dict]) -> None:
console.print("# GitHub Actions: Add these as repository secrets or use with actions/env")
for k in active:
prefix = _make_env_prefix(k)
console.print(f"echo \"{prefix}_ID={k['id']}\" >> $GITHUB_ENV")
console.print(f"echo \"{prefix}_SERVICE={k.get('service', '')}\" >> $GITHUB_ENV")
console.print(f"echo \"{prefix}_CREATED={k.get('created_at', '')}\" >> $GITHUB_ENV")
console.print(f'echo "{prefix}_ID={k["id"]}" >> $GITHUB_ENV')
console.print(f'echo "{prefix}_SERVICE={k.get("service", "")}" >> $GITHUB_ENV')
console.print(f'echo "{prefix}_CREATED={k.get("created_at", "")}" >> $GITHUB_ENV')
if k.get("expires_at"):
console.print(f"echo \"{prefix}_EXPIRES={k['expires_at']}\" >> $GITHUB_ENV")
console.print(f'echo "{prefix}_EXPIRES={k["expires_at"]}" >> $GITHUB_ENV')
console.print()
console.print("# Or add to .github/workflows/*.yml env: block:")
console.print("env:")
for k in active:
prefix = _make_env_prefix(k)
console.print(f" {prefix}_ID: \"{k['id']}\"")
console.print(f" {prefix}_SERVICE: \"{k.get('service', '')}\"")
console.print(f' {prefix}_ID: "{k["id"]}"')
console.print(f' {prefix}_SERVICE: "{k.get("service", "")}"')


# ── audit ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -499,9 +500,7 @@ def audit(ctx: click.Context, exit_on_expired: bool, exit_on_revoked: bool) -> N
console.print(f"[yellow]⚠ {len(expiring)} EXPIRING key(s) (within 7 days):[/yellow]")
for k in expiring:
console.print(
f" [yellow]{k['id']}[/yellow] "
f"{k.get('name', '')} — expires "
f"{_short_ts(k.get('expires_at', ''))}"
f" [yellow]{k['id']}[/yellow] {k.get('name', '')} — expires {_short_ts(k.get('expires_at', ''))}"
)
console.print()

Expand Down
26 changes: 14 additions & 12 deletions src/apiauth/keygen.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def generate_api_key(prefix: str = "ak", byte_length: int = 32) -> str:

def _base64url_no_pad(data: bytes) -> str:
import base64

return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


Expand Down Expand Up @@ -57,9 +58,9 @@ def create_api_key_entry(
now = _timestamp()
expiry = None
if expiry_days:
expiry = (
datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)
).isoformat(timespec="milliseconds")[:23] + "Z"
expiry = (datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)).isoformat(timespec="milliseconds")[
:23
] + "Z"

entry = {
"type": "api_key",
Expand Down Expand Up @@ -111,14 +112,15 @@ def create_jwt_entry(

# Create the JWT
import jwt as pyjwt

token = pyjwt.encode(payload, signing_secret, algorithm="HS256")

now_str = _timestamp()
expiry = None
if expiry_days:
expiry = (
datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)
).isoformat(timespec="milliseconds")[:23] + "Z"
expiry = (datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)).isoformat(timespec="milliseconds")[
:23
] + "Z"

entry = {
"type": "jwt",
Expand Down Expand Up @@ -158,9 +160,9 @@ def rotate_key(
now = _timestamp()
expiry = None
if expiry_days:
expiry = (
datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)
).isoformat(timespec="milliseconds")[:23] + "Z"
expiry = (datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)).isoformat(timespec="milliseconds")[
:23
] + "Z"

updated = dict(entry)
updated["previous_hash"] = entry.get("key_hash")
Expand Down Expand Up @@ -288,9 +290,9 @@ def rotate_jwt(
now = _timestamp()
expiry = None
if expiry_days:
expiry = (
datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)
).isoformat(timespec="milliseconds")[:23] + "Z"
expiry = (datetime.datetime.now(UTC) + datetime.timedelta(days=expiry_days)).isoformat(timespec="milliseconds")[
:23
] + "Z"

updated = dict(entry)
updated["previous_hash"] = entry.get("signing_secret_hash")
Expand Down
21 changes: 19 additions & 2 deletions src/apiauth/keystore.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import contextlib
import json
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
Expand Down Expand Up @@ -65,11 +66,27 @@ def _load(self) -> None:
) from exc

def _save(self) -> None:
"""Atomically write the keystore to disk.

Uses a temp file + os.replace so that a crash or disk-full mid-write
never truncates the existing store. The previous file remains intact
until the replacement is fully written.
"""
plaintext = json.dumps(self._entries, indent=2, default=str).encode("utf-8")
nonce = os.urandom(12)
ciphertext = self._aesgcm.encrypt(nonce, plaintext, None)
self._store_path.write_bytes(nonce + ciphertext)
os.chmod(str(self._store_path), 0o600)
data = nonce + ciphertext

tmp_path = self._store_path.with_suffix(self._store_path.suffix + ".tmp")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a unique temporary file for each save

When two threads or CLI processes save the same keystore concurrently, both write keys.json.tmp; one writer can replace or delete that shared file while the other is between write_bytes, chmod, and os.replace. This can make one command fail with FileNotFoundError, or let it report success after replacing the store with the other writer's encrypted payload. Create a unique temporary file in the keystore directory for each save, and add locking if concurrent read-modify-write operations must preserve both updates.

Useful? React with 👍 / 👎.

try:
tmp_path.write_bytes(data)
os.chmod(str(tmp_path), 0o600)
os.replace(str(tmp_path), str(self._store_path))
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fsync the replacement before promising crash safety

When the machine or filesystem crashes after os.replace, write_bytes may have flushed only to the OS cache; neither the temporary file nor the containing directory is fsynced. A filesystem can therefore persist the rename before the file contents, or lose the renamed directory entry, leaving keys.json missing or corrupt despite the stated crash-safety guarantee. Open and flush/fsync the temporary file before replacement, then fsync the keystore directory after replacement.

Useful? React with 👍 / 👎.

except BaseException:
# Clean up the temp file on any failure so we don't leak .tmp files.
with contextlib.suppress(OSError):
tmp_path.unlink(missing_ok=True)
raise

def get_all(self) -> dict[str, dict[str, Any]]:
"""Return all stored entries."""
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Mock revenueholdings_license for tests so CLI commands don't hit the paywall."""

import sys
from unittest.mock import MagicMock

Expand Down
Loading
Loading