427709e0a8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 lines
1.7 KiB
Python
52 lines
1.7 KiB
Python
"""API key generation, hashing, and prefix extraction (Stage 1 PR1).
|
|
|
|
Format is irreversible once business systems integrate (spec D5):
|
|
``dfk_live_<24>`` / ``dfk_test_<24>``. The public ``key_prefix`` is the
|
|
leading slice of the plaintext (``dfk_live_`` plus a few random chars,
|
|
length ``_PREFIX_LEN``) and is stored UNIQUE for audit logging; the DB
|
|
only ever stores ``sha256(plaintext)`` hex, never the plaintext.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
_PREFIX_LEN = 16
|
|
# token_urlsafe(18) yields ceil(18 * 4 / 3) = 24 url-safe chars.
|
|
_RANDOM_BYTES = 18
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GeneratedKey:
|
|
"""A freshly minted key. ``plaintext`` is returned to the caller
|
|
exactly once; only ``prefix`` + ``key_hash`` are persisted."""
|
|
|
|
plaintext: str
|
|
prefix: str
|
|
key_hash: str
|
|
|
|
|
|
def hash_api_key(plaintext: str) -> str:
|
|
"""Return the sha-256 hex digest of a plaintext token."""
|
|
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def split_prefix(plaintext: str) -> str:
|
|
"""Return the public, loggable prefix (first 16 chars) of a token."""
|
|
return plaintext[:_PREFIX_LEN]
|
|
|
|
|
|
def generate_api_key(env: Literal["live", "test"]) -> GeneratedKey:
|
|
"""Generate a new API key for the given environment.
|
|
|
|
Raises ``ValueError`` for any env other than ``"live"`` / ``"test"``.
|
|
"""
|
|
if env not in ("live", "test"):
|
|
raise ValueError(f"env must be 'live' or 'test', got {env!r}")
|
|
random_part = secrets.token_urlsafe(_RANDOM_BYTES)
|
|
plaintext = f"dfk_{env}_{random_part}"
|
|
return GeneratedKey(plaintext=plaintext, prefix=split_prefix(plaintext), key_hash=hash_api_key(plaintext))
|