Harden auth and security controls with session auth and docs
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user