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
This commit is contained in:
84
src/openrouter_monitor/services/token.py
Normal file
84
src/openrouter_monitor/services/token.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user