codeframe-ai 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Authentication module for CodeFRAME."""
|
|
2
|
+
from codeframe.auth.models import User
|
|
3
|
+
from codeframe.auth.schemas import UserRead, UserCreate, UserUpdate
|
|
4
|
+
from codeframe.auth.manager import fastapi_users, auth_backend, current_active_user
|
|
5
|
+
from codeframe.auth.dependencies import get_current_user
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"User",
|
|
9
|
+
"UserRead",
|
|
10
|
+
"UserCreate",
|
|
11
|
+
"UserUpdate",
|
|
12
|
+
"fastapi_users",
|
|
13
|
+
"auth_backend",
|
|
14
|
+
"current_active_user",
|
|
15
|
+
"get_current_user",
|
|
16
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""API key management endpoints.
|
|
2
|
+
|
|
3
|
+
Provides endpoints for creating, listing, and revoking API keys.
|
|
4
|
+
API key creation requires JWT authentication (not API key auth) to prevent
|
|
5
|
+
privilege escalation attacks.
|
|
6
|
+
|
|
7
|
+
Uses core/api_key_service.py for business logic (shared with CLI).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
16
|
+
from pydantic import BaseModel, field_validator
|
|
17
|
+
|
|
18
|
+
from codeframe.auth.dependencies import get_current_user, require_auth
|
|
19
|
+
from codeframe.auth.models import User
|
|
20
|
+
from codeframe.auth.api_keys import (
|
|
21
|
+
validate_scopes,
|
|
22
|
+
SCOPE_READ,
|
|
23
|
+
SCOPE_WRITE,
|
|
24
|
+
)
|
|
25
|
+
from codeframe.core.api_key_service import ApiKeyService
|
|
26
|
+
from codeframe.platform_store.database import Database
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/api/auth/api-keys", tags=["auth", "api-keys"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# Pydantic Models
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CreateApiKeyRequest(BaseModel):
|
|
39
|
+
"""Request body for creating an API key."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
scopes: List[str] = [SCOPE_READ, SCOPE_WRITE]
|
|
43
|
+
expires_at: Optional[datetime] = None
|
|
44
|
+
|
|
45
|
+
@field_validator("scopes")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_scopes_field(cls, v: List[str]) -> List[str]:
|
|
48
|
+
if not validate_scopes(v):
|
|
49
|
+
raise ValueError(f"Invalid scopes: {v}. Valid scopes are: read, write, admin")
|
|
50
|
+
# Deduplicate while preserving order
|
|
51
|
+
seen = set()
|
|
52
|
+
deduped = [s for s in v if not (s in seen or seen.add(s))]
|
|
53
|
+
if not deduped:
|
|
54
|
+
raise ValueError("At least one scope is required")
|
|
55
|
+
return deduped
|
|
56
|
+
|
|
57
|
+
@field_validator("expires_at")
|
|
58
|
+
@classmethod
|
|
59
|
+
def validate_expires_at(cls, v: Optional[datetime]) -> Optional[datetime]:
|
|
60
|
+
if v is not None:
|
|
61
|
+
now = datetime.now(timezone.utc)
|
|
62
|
+
# Handle naive datetime by assuming UTC
|
|
63
|
+
if v.tzinfo is None:
|
|
64
|
+
v = v.replace(tzinfo=timezone.utc)
|
|
65
|
+
if v <= now:
|
|
66
|
+
raise ValueError("expires_at must be in the future")
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CreateApiKeyResponse(BaseModel):
|
|
71
|
+
"""Response body for API key creation."""
|
|
72
|
+
|
|
73
|
+
key: str # Full key - shown only once
|
|
74
|
+
id: str
|
|
75
|
+
prefix: str
|
|
76
|
+
created_at: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ApiKeyInfoResponse(BaseModel):
|
|
80
|
+
"""API key information (without sensitive data)."""
|
|
81
|
+
|
|
82
|
+
id: str
|
|
83
|
+
name: str
|
|
84
|
+
prefix: str
|
|
85
|
+
scopes: List[str]
|
|
86
|
+
created_at: str
|
|
87
|
+
last_used_at: Optional[str]
|
|
88
|
+
expires_at: Optional[str]
|
|
89
|
+
is_active: bool
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RevokeApiKeyResponse(BaseModel):
|
|
93
|
+
"""Response body for API key revocation."""
|
|
94
|
+
|
|
95
|
+
id: str
|
|
96
|
+
revoked: bool
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# Database Helper
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_db(request: Request) -> Database:
|
|
105
|
+
"""Get database instance from app state (singleton managed by lifespan handler).
|
|
106
|
+
|
|
107
|
+
Uses the app-scoped database to avoid per-request connection leaks.
|
|
108
|
+
Falls back to DATABASE_PATH env var if app.state.db not available.
|
|
109
|
+
"""
|
|
110
|
+
# Prefer app-scoped singleton (set by lifespan handler in server.py)
|
|
111
|
+
db = getattr(request.app.state, "db", None)
|
|
112
|
+
if db is not None:
|
|
113
|
+
return db
|
|
114
|
+
|
|
115
|
+
# Fallback for tests or standalone usage
|
|
116
|
+
db = getattr(request.state, "db", None)
|
|
117
|
+
if db is None:
|
|
118
|
+
logger.warning("No db in app.state, creating fallback connection")
|
|
119
|
+
db_path = os.getenv(
|
|
120
|
+
"DATABASE_PATH",
|
|
121
|
+
os.path.join(os.getcwd(), ".codeframe", "state.db")
|
|
122
|
+
)
|
|
123
|
+
db = Database(db_path)
|
|
124
|
+
db.initialize()
|
|
125
|
+
request.state.db = db
|
|
126
|
+
return db
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_api_key_service(request: Request) -> ApiKeyService:
|
|
130
|
+
"""Get API key service instance."""
|
|
131
|
+
db = get_db(request)
|
|
132
|
+
return ApiKeyService(db)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# Endpoints
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.post("", status_code=status.HTTP_201_CREATED, response_model=CreateApiKeyResponse)
|
|
141
|
+
async def create_api_key(
|
|
142
|
+
request: Request,
|
|
143
|
+
body: CreateApiKeyRequest,
|
|
144
|
+
current_user: User = Depends(get_current_user), # JWT required, not API key
|
|
145
|
+
):
|
|
146
|
+
"""Create a new API key.
|
|
147
|
+
|
|
148
|
+
**Important**: This endpoint requires JWT authentication. API keys cannot
|
|
149
|
+
be used to create new API keys (prevents privilege escalation).
|
|
150
|
+
|
|
151
|
+
The full API key is returned only once. Store it securely - it cannot
|
|
152
|
+
be retrieved again.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
body: API key configuration (name, scopes, optional expiration)
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Created API key details including the full key (shown once)
|
|
159
|
+
"""
|
|
160
|
+
service = get_api_key_service(request)
|
|
161
|
+
|
|
162
|
+
result = service.create_api_key(
|
|
163
|
+
user_id=current_user.id,
|
|
164
|
+
name=body.name,
|
|
165
|
+
scopes=body.scopes,
|
|
166
|
+
expires_at=body.expires_at,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return CreateApiKeyResponse(
|
|
170
|
+
key=result.key,
|
|
171
|
+
id=result.id,
|
|
172
|
+
prefix=result.prefix,
|
|
173
|
+
created_at=result.created_at,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.get("", response_model=List[ApiKeyInfoResponse])
|
|
178
|
+
async def list_api_keys(
|
|
179
|
+
request: Request,
|
|
180
|
+
auth: dict = Depends(require_auth), # Either JWT or API key
|
|
181
|
+
):
|
|
182
|
+
"""List all API keys for the authenticated user.
|
|
183
|
+
|
|
184
|
+
Does not expose the key hash or full key. Shows prefix for identification.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of API key information
|
|
188
|
+
"""
|
|
189
|
+
service = get_api_key_service(request)
|
|
190
|
+
|
|
191
|
+
keys = service.list_api_keys(user_id=auth["user_id"])
|
|
192
|
+
|
|
193
|
+
return [
|
|
194
|
+
ApiKeyInfoResponse(
|
|
195
|
+
id=k.id,
|
|
196
|
+
name=k.name,
|
|
197
|
+
prefix=k.prefix,
|
|
198
|
+
scopes=k.scopes,
|
|
199
|
+
created_at=k.created_at,
|
|
200
|
+
last_used_at=k.last_used_at,
|
|
201
|
+
expires_at=k.expires_at,
|
|
202
|
+
is_active=k.is_active,
|
|
203
|
+
)
|
|
204
|
+
for k in keys
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.delete("/{key_id}", response_model=RevokeApiKeyResponse)
|
|
209
|
+
async def revoke_api_key(
|
|
210
|
+
request: Request,
|
|
211
|
+
key_id: str,
|
|
212
|
+
auth: dict = Depends(require_auth), # Either JWT or API key
|
|
213
|
+
):
|
|
214
|
+
"""Revoke an API key.
|
|
215
|
+
|
|
216
|
+
The key will be marked as inactive and can no longer be used for
|
|
217
|
+
authentication.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
key_id: The API key ID to revoke
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Confirmation of revocation
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
404: If key not found or not owned by user
|
|
227
|
+
"""
|
|
228
|
+
service = get_api_key_service(request)
|
|
229
|
+
|
|
230
|
+
success = service.revoke_api_key(key_id, user_id=auth["user_id"])
|
|
231
|
+
|
|
232
|
+
if not success:
|
|
233
|
+
raise HTTPException(
|
|
234
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
235
|
+
detail="API key not found",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return RevokeApiKeyResponse(id=key_id, revoked=True)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""API key generation, verification, and utility functions.
|
|
2
|
+
|
|
3
|
+
Provides utilities for creating and validating API keys for server authentication.
|
|
4
|
+
API keys follow the format: cf_{environment}_{32_hex_chars}
|
|
5
|
+
|
|
6
|
+
Security considerations:
|
|
7
|
+
- Keys are hashed using SHA256 (industry standard for high-entropy API keys)
|
|
8
|
+
- API keys have 128 bits of entropy from secrets.token_hex(16)
|
|
9
|
+
- SHA256 is appropriate here (vs bcrypt for passwords) because API keys
|
|
10
|
+
are already high-entropy random strings, not human-chosen passwords
|
|
11
|
+
- Only the prefix is stored for efficient lookup
|
|
12
|
+
- Full key is shown only once during creation
|
|
13
|
+
|
|
14
|
+
This approach matches GitHub, Stripe, and other API key implementations.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import secrets
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Tuple
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Scope constants for API key permissions
|
|
26
|
+
SCOPE_READ = "read"
|
|
27
|
+
SCOPE_WRITE = "write"
|
|
28
|
+
SCOPE_ADMIN = "admin"
|
|
29
|
+
|
|
30
|
+
# All valid scopes
|
|
31
|
+
VALID_SCOPES = frozenset({SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN})
|
|
32
|
+
|
|
33
|
+
# Key format constants
|
|
34
|
+
# PREFIX_LENGTH=12 captures "cf_live_" (8) + 4 random chars for efficient DB lookup
|
|
35
|
+
# Example: "cf_live_1abc" from "cf_live_1abc2b2f78d93788b9fa00e383832be3"
|
|
36
|
+
KEY_PREFIX_LENGTH = 12
|
|
37
|
+
KEY_RANDOM_BYTES = 16 # 16 bytes = 32 hex characters
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_api_key(environment: str = "live") -> Tuple[str, str, str]:
|
|
41
|
+
"""Generate a new API key with SHA256 hash.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
environment: Environment identifier ('live' or 'test')
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (full_key, key_hash, prefix):
|
|
48
|
+
- full_key: The complete API key to give to the user (shown once)
|
|
49
|
+
- key_hash: SHA256 hash of the key for storage (bcrypt-like format)
|
|
50
|
+
- prefix: First 12 characters for efficient lookup
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> key, hash, prefix = generate_api_key()
|
|
54
|
+
>>> key
|
|
55
|
+
'cf_live_a1b2c3d4e5f6789012345678abcdef01'
|
|
56
|
+
>>> prefix
|
|
57
|
+
'cf_live_a1b2'
|
|
58
|
+
"""
|
|
59
|
+
# Generate 16 random bytes = 32 hex characters (128 bits entropy)
|
|
60
|
+
random_part = secrets.token_hex(KEY_RANDOM_BYTES)
|
|
61
|
+
|
|
62
|
+
# Construct the full key: cf_{environment}_{32_hex}
|
|
63
|
+
full_key = f"cf_{environment}_{random_part}"
|
|
64
|
+
|
|
65
|
+
# Hash using SHA256 - appropriate for high-entropy API keys
|
|
66
|
+
# Format: $sha256${hex_digest} to distinguish from bcrypt hashes
|
|
67
|
+
digest = hashlib.sha256(full_key.encode()).hexdigest()
|
|
68
|
+
key_hash = f"$sha256${digest}"
|
|
69
|
+
|
|
70
|
+
# Extract prefix for database lookup optimization
|
|
71
|
+
prefix = full_key[:KEY_PREFIX_LENGTH]
|
|
72
|
+
|
|
73
|
+
logger.debug(f"Generated API key with prefix {prefix}")
|
|
74
|
+
|
|
75
|
+
return full_key, key_hash, prefix
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify_api_key(key: str, key_hash: str) -> bool:
|
|
79
|
+
"""Verify an API key against its stored hash.
|
|
80
|
+
|
|
81
|
+
Supports both SHA256 (preferred for API keys) and bcrypt hashes.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
key: The full API key provided by the user
|
|
85
|
+
key_hash: The stored hash (SHA256 or bcrypt format)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if the key matches the hash, False otherwise
|
|
89
|
+
|
|
90
|
+
Note:
|
|
91
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
92
|
+
Returns False (not exception) for invalid inputs.
|
|
93
|
+
"""
|
|
94
|
+
if not key or not key_hash:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if key_hash.startswith("$sha256$"):
|
|
99
|
+
# SHA256 hash verification with constant-time comparison
|
|
100
|
+
expected_digest = key_hash[8:] # Remove "$sha256$" prefix
|
|
101
|
+
actual_digest = hashlib.sha256(key.encode()).hexdigest()
|
|
102
|
+
return hmac.compare_digest(expected_digest, actual_digest)
|
|
103
|
+
|
|
104
|
+
elif key_hash.startswith("$2"):
|
|
105
|
+
# Bcrypt hash (legacy or if we switch back)
|
|
106
|
+
# Import here to avoid startup issues with bcrypt compatibility
|
|
107
|
+
from passlib.context import CryptContext
|
|
108
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
109
|
+
return pwd_context.verify(key, key_hash)
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
# Unknown hash format
|
|
113
|
+
logger.warning(f"Unknown hash format: {key_hash[:10]}...")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
# Handle malformed hashes gracefully
|
|
118
|
+
logger.debug(f"API key verification failed: {e}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_prefix(key: str) -> str:
|
|
123
|
+
"""Extract the prefix from a full API key for database lookup.
|
|
124
|
+
|
|
125
|
+
The prefix is the first 12 characters (cf_{env}_xxxx) which provides
|
|
126
|
+
enough entropy for efficient indexed lookup while avoiding full key
|
|
127
|
+
comparison in the database.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
key: The full API key
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The 12-character prefix
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If key is shorter than 12 characters
|
|
137
|
+
"""
|
|
138
|
+
if len(key) < KEY_PREFIX_LENGTH:
|
|
139
|
+
raise ValueError(f"API key too short: expected at least {KEY_PREFIX_LENGTH} chars")
|
|
140
|
+
|
|
141
|
+
return key[:KEY_PREFIX_LENGTH]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_scopes(scopes: list[str]) -> bool:
|
|
145
|
+
"""Validate that all scopes in the list are valid.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
scopes: List of scope strings to validate
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if all scopes are valid and list is non-empty, False otherwise
|
|
152
|
+
"""
|
|
153
|
+
if not scopes:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
return all(scope in VALID_SCOPES for scope in scopes)
|