40 lines
1.4 KiB
Python
40 lines
1.4 KiB
Python
"""API-key generation and hashing (ADR-0008, ADR-0009).
|
|
|
|
Keys are `sk_{prefix}_{secret}`. `prefix` is non-secret and indexed
|
|
(`api_keys.key_prefix`); `secret` is 256 bits of `secrets.token_urlsafe`
|
|
entropy, stored only as a SHA-256 hash. A random 256-bit secret does not
|
|
benefit from a slow password-hashing KDF the way a human-chosen password
|
|
does — the cost that defends against dictionary/brute-force guessing over a
|
|
low-entropy input has nothing to defend here, and would only tax every
|
|
request. Comparison is constant-time to avoid a hash-timing oracle.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
|
|
_PREFIX_LENGTH = 16
|
|
|
|
|
|
def generate_api_key() -> tuple[str, str, str]:
|
|
"""Return `(key_prefix, secret, full_key)` for a newly issued key."""
|
|
key_prefix = secrets.token_hex(_PREFIX_LENGTH // 2)
|
|
secret = secrets.token_urlsafe(32)
|
|
return key_prefix, secret, f"sk_{key_prefix}_{secret}"
|
|
|
|
|
|
def parse_api_key(full_key: str) -> tuple[str, str] | None:
|
|
"""Return `(key_prefix, secret)`, or `None` if the token is malformed."""
|
|
parts = full_key.split("_", 2)
|
|
if len(parts) != 3 or parts[0] != "sk" or not parts[1] or not parts[2]:
|
|
return None
|
|
return parts[1], parts[2]
|
|
|
|
|
|
def hash_secret(secret: str) -> str:
|
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def verify_secret(secret: str, key_hash: str) -> bool:
|
|
return hmac.compare_digest(hash_secret(secret), key_hash)
|