Harden auth and security controls with session auth and docs

This commit is contained in:
2026-03-01 15:29:09 -03:00
parent 7a19f22f41
commit 0242e061c2
36 changed files with 1794 additions and 505 deletions

View File

@@ -11,6 +11,14 @@ import secrets
from pathlib import Path
from typing import Any
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # pragma: no cover - dependency failures are surfaced at runtime usage.
Fernet = None # type: ignore[assignment]
class InvalidToken(Exception):
"""Fallback InvalidToken type used when cryptography dependency import fails."""
from app.core.config import get_settings, normalize_and_validate_provider_base_url
@@ -63,12 +71,13 @@ DEFAULT_ROUTING_PROMPT = (
"Confidence must be between 0 and 1."
)
PROVIDER_API_KEY_CIPHERTEXT_PREFIX = "enc-v1"
PROVIDER_API_KEY_CIPHERTEXT_PREFIX = "enc-v2"
PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX = "enc-v1"
PROVIDER_API_KEY_KEYFILE_NAME = ".settings-api-key"
PROVIDER_API_KEY_STREAM_CONTEXT = b"dcm-provider-api-key-stream"
PROVIDER_API_KEY_AUTH_CONTEXT = b"dcm-provider-api-key-auth"
PROVIDER_API_KEY_NONCE_BYTES = 16
PROVIDER_API_KEY_TAG_BYTES = 32
PROVIDER_API_KEY_LEGACY_STREAM_CONTEXT = b"dcm-provider-api-key-stream"
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT = b"dcm-provider-api-key-auth"
PROVIDER_API_KEY_LEGACY_NONCE_BYTES = 16
PROVIDER_API_KEY_LEGACY_TAG_BYTES = 32
def _settings_api_key_path() -> Path:
@@ -128,14 +137,14 @@ def _derive_provider_api_key_key() -> bytes:
return generated
def _xor_bytes(left: bytes, right: bytes) -> bytes:
"""Applies byte-wise XOR for equal-length byte sequences."""
def _legacy_xor_bytes(left: bytes, right: bytes) -> bytes:
"""Applies byte-wise XOR for equal-length byte sequences used by legacy ciphertext migration."""
return bytes(first ^ second for first, second in zip(left, right))
def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) -> bytes:
"""Derives deterministic stream bytes from HMAC-SHA256 blocks for payload masking."""
def _legacy_derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) -> bytes:
"""Derives legacy deterministic stream bytes from HMAC-SHA256 blocks for migration reads."""
stream = bytearray()
counter = 0
@@ -143,7 +152,7 @@ def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) ->
counter_bytes = counter.to_bytes(4, "big")
block = hmac.new(
master_key,
PROVIDER_API_KEY_STREAM_CONTEXT + nonce + counter_bytes,
PROVIDER_API_KEY_LEGACY_STREAM_CONTEXT + nonce + counter_bytes,
hashlib.sha256,
).digest()
stream.extend(block)
@@ -151,6 +160,33 @@ def _derive_stream_cipher_bytes(master_key: bytes, nonce: bytes, length: int) ->
return bytes(stream[:length])
def _provider_key_fernet(master_key: bytes) -> Fernet:
"""Builds Fernet instance from 32-byte symmetric key material."""
if Fernet is None:
raise AppSettingsValidationError("cryptography dependency is not available")
fernet_key = base64.urlsafe_b64encode(master_key[:32])
return Fernet(fernet_key)
def _encrypt_provider_api_key_fallback(value: str) -> str:
"""Encrypts provider keys with legacy HMAC stream construction when cryptography is unavailable."""
plaintext = value.encode("utf-8")
master_key = _derive_provider_api_key_key()
nonce = secrets.token_bytes(PROVIDER_API_KEY_LEGACY_NONCE_BYTES)
keystream = _legacy_derive_stream_cipher_bytes(master_key, nonce, len(plaintext))
ciphertext = _legacy_xor_bytes(plaintext, keystream)
tag = hmac.new(
master_key,
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
payload = nonce + ciphertext + tag
encoded = _urlsafe_b64encode_no_padding(payload)
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{encoded}"
def _encrypt_provider_api_key(value: str) -> str:
"""Encrypts one provider API key for at-rest JSON persistence."""
@@ -158,19 +194,52 @@ def _encrypt_provider_api_key(value: str) -> str:
if not normalized:
return ""
plaintext = normalized.encode("utf-8")
if Fernet is None:
return _encrypt_provider_api_key_fallback(normalized)
master_key = _derive_provider_api_key_key()
nonce = secrets.token_bytes(PROVIDER_API_KEY_NONCE_BYTES)
keystream = _derive_stream_cipher_bytes(master_key, nonce, len(plaintext))
ciphertext = _xor_bytes(plaintext, keystream)
tag = hmac.new(
token = _provider_key_fernet(master_key).encrypt(normalized.encode("utf-8")).decode("ascii")
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{token}"
def _decrypt_provider_api_key_legacy_payload(encoded_payload: str) -> str:
"""Decrypts legacy stream-cipher payload bytes used for migration and fallback reads."""
if not encoded_payload:
raise AppSettingsValidationError("Provider API key ciphertext is missing payload bytes")
try:
payload = _urlsafe_b64decode_no_padding(encoded_payload)
except (binascii.Error, ValueError) as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid base64") from error
minimum_length = PROVIDER_API_KEY_LEGACY_NONCE_BYTES + PROVIDER_API_KEY_LEGACY_TAG_BYTES
if len(payload) < minimum_length:
raise AppSettingsValidationError("Provider API key ciphertext payload is truncated")
nonce = payload[:PROVIDER_API_KEY_LEGACY_NONCE_BYTES]
ciphertext = payload[PROVIDER_API_KEY_LEGACY_NONCE_BYTES:-PROVIDER_API_KEY_LEGACY_TAG_BYTES]
received_tag = payload[-PROVIDER_API_KEY_LEGACY_TAG_BYTES:]
master_key = _derive_provider_api_key_key()
expected_tag = hmac.new(
master_key,
PROVIDER_API_KEY_AUTH_CONTEXT + nonce + ciphertext,
PROVIDER_API_KEY_LEGACY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
payload = nonce + ciphertext + tag
encoded = _urlsafe_b64encode_no_padding(payload)
return f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:{encoded}"
if not hmac.compare_digest(received_tag, expected_tag):
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed")
keystream = _legacy_derive_stream_cipher_bytes(master_key, nonce, len(ciphertext))
plaintext = _legacy_xor_bytes(ciphertext, keystream)
try:
return plaintext.decode("utf-8").strip()
except UnicodeDecodeError as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid UTF-8") from error
def _decrypt_provider_api_key_legacy(value: str) -> str:
"""Decrypts legacy `enc-v1` payloads to support non-breaking key migration."""
encoded_payload = value.split(":", 1)[1]
return _decrypt_provider_api_key_legacy_payload(encoded_payload)
def _decrypt_provider_api_key(value: str) -> str:
@@ -179,35 +248,23 @@ def _decrypt_provider_api_key(value: str) -> str:
normalized = value.strip()
if not normalized:
return ""
if not normalized.startswith(f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:"):
if not normalized.startswith(f"{PROVIDER_API_KEY_CIPHERTEXT_PREFIX}:") and not normalized.startswith(
f"{PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX}:"
):
return normalized
encoded_payload = normalized.split(":", 1)[1]
if not encoded_payload:
if normalized.startswith(f"{PROVIDER_API_KEY_LEGACY_CIPHERTEXT_PREFIX}:"):
return _decrypt_provider_api_key_legacy(normalized)
token = normalized.split(":", 1)[1].strip()
if not token:
raise AppSettingsValidationError("Provider API key ciphertext is missing payload bytes")
if Fernet is None:
return _decrypt_provider_api_key_legacy_payload(token)
try:
payload = _urlsafe_b64decode_no_padding(encoded_payload)
except (binascii.Error, ValueError) as error:
raise AppSettingsValidationError("Provider API key ciphertext is not valid base64") from error
minimum_length = PROVIDER_API_KEY_NONCE_BYTES + PROVIDER_API_KEY_TAG_BYTES
if len(payload) < minimum_length:
raise AppSettingsValidationError("Provider API key ciphertext payload is truncated")
nonce = payload[:PROVIDER_API_KEY_NONCE_BYTES]
ciphertext = payload[PROVIDER_API_KEY_NONCE_BYTES:-PROVIDER_API_KEY_TAG_BYTES]
received_tag = payload[-PROVIDER_API_KEY_TAG_BYTES:]
master_key = _derive_provider_api_key_key()
expected_tag = hmac.new(
master_key,
PROVIDER_API_KEY_AUTH_CONTEXT + nonce + ciphertext,
hashlib.sha256,
).digest()
if not hmac.compare_digest(received_tag, expected_tag):
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed")
keystream = _derive_stream_cipher_bytes(master_key, nonce, len(ciphertext))
plaintext = _xor_bytes(ciphertext, keystream)
plaintext = _provider_key_fernet(_derive_provider_api_key_key()).decrypt(token.encode("ascii"))
except (InvalidToken, ValueError, UnicodeEncodeError) as error:
raise AppSettingsValidationError("Provider API key ciphertext integrity check failed") from error
try:
return plaintext.decode("utf-8").strip()
except UnicodeDecodeError as error: