Files
openrouter-watcher/src/openrouter_monitor/services/token.py
Luca Sacchi Ricciardi 649ff76d6c feat(security): T15 implement API token generation
- Add generate_api_token with format 'or_api_' + 48 bytes random
- Implement hash_token with SHA-256
- Add verify_api_token with timing-safe comparison (secrets.compare_digest)
- Only hash stored in DB, plaintext shown once
- 20 comprehensive tests with 100% coverage
- Handle TypeError for non-string inputs
2026-04-07 12:12:39 +02:00

85 lines
2.4 KiB
Python

"""API token generation and verification service.
This module provides secure API token generation using cryptographically
secure random generation and SHA-256 hashing. Only the hash is stored.
"""
import hashlib
import secrets
TOKEN_PREFIX = "or_api_"
TOKEN_ENTROPY_BYTES = 48 # Results in ~64 URL-safe base64 chars
def generate_api_token() -> tuple[str, str]:
"""Generate a new API token.
Generates a cryptographically secure random token with format:
'or_api_' + 48 bytes of URL-safe base64 (~64 chars)
Returns:
Tuple of (plaintext_token, token_hash) where:
- plaintext_token: The full token to show once to the user
- token_hash: SHA-256 hash to store in database
Example:
>>> plaintext, hash = generate_api_token()
>>> print(plaintext)
'or_api_x9QzGv2K...'
>>> # Store hash in DB, show plaintext to user once
"""
# Generate cryptographically secure random token
random_part = secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)
plaintext = f"{TOKEN_PREFIX}{random_part}"
# Hash the entire token
token_hash = hash_token(plaintext)
return plaintext, token_hash
def hash_token(plaintext: str) -> str:
"""Hash a token using SHA-256.
Args:
plaintext: The plaintext token to hash.
Returns:
Hexadecimal string of the SHA-256 hash.
Raises:
TypeError: If plaintext is not a string.
"""
if not isinstance(plaintext, str):
raise TypeError("plaintext must be a string")
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def verify_api_token(plaintext: str, token_hash: str) -> bool:
"""Verify an API token against its stored hash.
Uses timing-safe comparison to prevent timing attacks.
Args:
plaintext: The plaintext token provided by the user.
token_hash: The SHA-256 hash stored in the database.
Returns:
True if the token matches the hash, False otherwise.
Raises:
TypeError: If either argument is not a string.
"""
if not isinstance(plaintext, str):
raise TypeError("plaintext must be a string")
if not isinstance(token_hash, str):
raise TypeError("token_hash must be a string")
# Compute hash of provided plaintext
computed_hash = hash_token(plaintext)
# Use timing-safe comparison to prevent timing attacks
return secrets.compare_digest(computed_hash, token_hash)