Merge stegasoo (v4.3.0, steganography) and verisoo (v0.1.0, attestation) as subpackages under soosef.stegasoo and soosef.verisoo. This eliminates cross-repo coordination and enables atomic changes across the full stack. - Copy stegasoo (34 modules) and verisoo (15 modules) into src/soosef/ - Convert all verisoo absolute imports to relative imports - Rewire ~50 import sites across soosef code (cli, web, keystore, tests) - Replace stegasoo/verisoo pip deps with inlined code + pip extras (stego-dct, stego-audio, attest, web, api, cli, fieldkit, all, dev) - Add _availability.py for runtime feature detection - Add unified FastAPI mount point at soosef.api - Copy and adapt tests from both repos (155 pass, 1 skip) - Drop standalone CLI/web frontends; keep FastAPI as optional modules - Both source repos tagged pre-monorepo-consolidation on GitHub Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
258 lines
6.9 KiB
Python
258 lines
6.9 KiB
Python
"""
|
|
API Key Authentication for Stegasoo REST API.
|
|
|
|
Provides simple API key authentication with hashed key storage.
|
|
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
|
|
|
Usage:
|
|
from .auth import require_api_key, get_api_key_status
|
|
|
|
@app.get("/protected")
|
|
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
|
return {"status": "authenticated"}
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException, Security
|
|
from fastapi.security import APIKeyHeader
|
|
|
|
# API key header name
|
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
# Config locations
|
|
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
|
PROJECT_CONFIG_DIR = Path("./config")
|
|
|
|
# Key file name
|
|
API_KEYS_FILE = "api_keys.json"
|
|
|
|
# Environment variable for API key (alternative to file)
|
|
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
|
|
|
|
|
def _hash_key(key: str) -> str:
|
|
"""Hash an API key for storage."""
|
|
return hashlib.sha256(key.encode()).hexdigest()
|
|
|
|
|
|
def _get_keys_file(location: str = "user") -> Path:
|
|
"""Get path to API keys file."""
|
|
if location == "project":
|
|
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
|
return USER_CONFIG_DIR / API_KEYS_FILE
|
|
|
|
|
|
def _load_keys(location: str = "user") -> dict:
|
|
"""Load API keys from config file."""
|
|
keys_file = _get_keys_file(location)
|
|
if keys_file.exists():
|
|
try:
|
|
with open(keys_file) as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"keys": [], "enabled": True}
|
|
return {"keys": [], "enabled": True}
|
|
|
|
|
|
def _save_keys(data: dict, location: str = "user") -> None:
|
|
"""Save API keys to config file."""
|
|
keys_file = _get_keys_file(location)
|
|
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(keys_file, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
|
|
# Secure permissions (owner read/write only)
|
|
os.chmod(keys_file, 0o600)
|
|
|
|
|
|
def generate_api_key() -> str:
|
|
"""Generate a new API key."""
|
|
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
# 32 bytes = 256 bits of entropy
|
|
random_part = secrets.token_hex(16)
|
|
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
|
|
|
|
|
def add_api_key(name: str, location: str = "user") -> str:
|
|
"""
|
|
Generate and store a new API key.
|
|
|
|
Args:
|
|
name: Descriptive name for the key (e.g., "laptop", "automation")
|
|
location: "user" or "project"
|
|
|
|
Returns:
|
|
The generated API key (only shown once!)
|
|
"""
|
|
key = generate_api_key()
|
|
key_hash = _hash_key(key)
|
|
|
|
data = _load_keys(location)
|
|
|
|
# Check for duplicate name
|
|
for existing in data["keys"]:
|
|
if existing["name"] == name:
|
|
raise ValueError(f"Key with name '{name}' already exists")
|
|
|
|
data["keys"].append(
|
|
{
|
|
"name": name,
|
|
"hash": key_hash,
|
|
"created": __import__("datetime").datetime.now().isoformat(),
|
|
}
|
|
)
|
|
|
|
_save_keys(data, location)
|
|
|
|
return key
|
|
|
|
|
|
def remove_api_key(name: str, location: str = "user") -> bool:
|
|
"""
|
|
Remove an API key by name.
|
|
|
|
Returns:
|
|
True if key was found and removed, False otherwise
|
|
"""
|
|
data = _load_keys(location)
|
|
original_count = len(data["keys"])
|
|
|
|
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
|
|
|
if len(data["keys"]) < original_count:
|
|
_save_keys(data, location)
|
|
return True
|
|
return False
|
|
|
|
|
|
def list_api_keys(location: str = "user") -> list[dict]:
|
|
"""
|
|
List all API keys (names and creation dates, not actual keys).
|
|
"""
|
|
data = _load_keys(location)
|
|
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
|
|
|
|
|
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
|
"""Enable or disable API key authentication."""
|
|
data = _load_keys(location)
|
|
data["enabled"] = enabled
|
|
_save_keys(data, location)
|
|
|
|
|
|
def is_auth_enabled() -> bool:
|
|
"""Check if API key authentication is enabled."""
|
|
# Check project config first, then user config
|
|
for location in ["project", "user"]:
|
|
data = _load_keys(location)
|
|
if "enabled" in data:
|
|
return data["enabled"]
|
|
|
|
# Default: enabled if any keys exist
|
|
return bool(get_all_key_hashes())
|
|
|
|
|
|
def get_all_key_hashes() -> set[str]:
|
|
"""Get all valid API key hashes from all sources."""
|
|
hashes = set()
|
|
|
|
# Check environment variable first
|
|
env_key = os.environ.get(API_KEY_ENV_VAR)
|
|
if env_key:
|
|
hashes.add(_hash_key(env_key))
|
|
|
|
# Check project and user configs
|
|
for location in ["project", "user"]:
|
|
data = _load_keys(location)
|
|
for key_entry in data.get("keys", []):
|
|
if "hash" in key_entry:
|
|
hashes.add(key_entry["hash"])
|
|
|
|
return hashes
|
|
|
|
|
|
def validate_api_key(key: str) -> bool:
|
|
"""Validate an API key against stored hashes."""
|
|
if not key:
|
|
return False
|
|
|
|
key_hash = _hash_key(key)
|
|
valid_hashes = get_all_key_hashes()
|
|
|
|
return key_hash in valid_hashes
|
|
|
|
|
|
def get_api_key_status() -> dict:
|
|
"""Get current API key authentication status."""
|
|
user_keys = list_api_keys("user")
|
|
project_keys = list_api_keys("project")
|
|
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
|
|
|
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
|
|
|
return {
|
|
"enabled": is_auth_enabled(),
|
|
"total_keys": total_keys,
|
|
"user_keys": len(user_keys),
|
|
"project_keys": len(project_keys),
|
|
"env_configured": env_configured,
|
|
"keys": {
|
|
"user": user_keys,
|
|
"project": project_keys,
|
|
},
|
|
}
|
|
|
|
|
|
# FastAPI dependency for API key authentication
|
|
async def require_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str:
|
|
"""
|
|
FastAPI dependency that requires a valid API key.
|
|
|
|
Usage:
|
|
@app.get("/protected")
|
|
async def endpoint(key: str = Depends(require_api_key)):
|
|
...
|
|
"""
|
|
# Check if auth is enabled
|
|
if not is_auth_enabled():
|
|
return "auth_disabled"
|
|
|
|
# No keys configured = auth disabled
|
|
if not get_all_key_hashes():
|
|
return "no_keys_configured"
|
|
|
|
# Validate the provided key
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="API key required. Provide X-API-Key header.",
|
|
headers={"WWW-Authenticate": "ApiKey"},
|
|
)
|
|
|
|
if not validate_api_key(api_key):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Invalid API key.",
|
|
)
|
|
|
|
return api_key
|
|
|
|
|
|
async def optional_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
|
|
"""
|
|
FastAPI dependency that optionally validates API key.
|
|
|
|
Returns the key if valid, None if not provided or invalid.
|
|
Doesn't raise exceptions - useful for endpoints that work
|
|
with or without auth.
|
|
"""
|
|
if api_key and validate_api_key(api_key):
|
|
return api_key
|
|
return None
|