Skip to content

Commit 5a6903b

Browse files
authored
feat(encryption): Add Fernet Key Store (#103511)
Introduces `FernetKeyStore` which is a util singleton class used for loading and retrieving Fernet keys. For now, keys are loaded on first usage, and support for loading keys on star up will be introduced as a part of a separate PR. With the `FernetKeyStore` we are also able to simplify the logic in `EncryptedFiled` base class because now all of the logic for handling keys lives elsewhere.
1 parent 27e7094 commit 5a6903b

File tree

6 files changed

+768
-382
lines changed

6 files changed

+768
-382
lines changed

src/sentry/conf/server.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
SENTRY_API_PAGINATION_ALLOWLIST_DO_NOT_MODIFY,
2121
)
2222
from sentry.conf.types.bgtask import BgTaskConfig
23+
from sentry.conf.types.encrypted_field import EncryptedFieldSettings
2324
from sentry.conf.types.kafka_definition import ConsumerDefinition
2425
from sentry.conf.types.logging_config import LoggingConfig
2526
from sentry.conf.types.region_config import RegionConfig
@@ -794,18 +795,14 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
794795
},
795796
}
796797

797-
# Fernet keys for database encryption.
798-
# First key in the dict is used as a primary key, and if
799-
# encryption method options is "fernet", the first key will be
800-
# used to decrypt the data.
801-
#
802-
# Other keys are used only for data decryption. This structure
803-
# is used to allow easier key rotation when "fernet" is used
804-
# as an encryption method.
805-
DATABASE_ENCRYPTION_FERNET_KEYS = {
806-
os.getenv("DATABASE_ENCRYPTION_KEY_ID_1"): os.getenv("DATABASE_ENCRYPTION_FERNET_KEY_1"),
798+
# Settings for encrypted database fields.
799+
DATABASE_ENCRYPTION_SETTINGS: EncryptedFieldSettings = {
800+
"method": "plaintext",
801+
"fernet_primary_key_id": os.getenv("DATABASE_ENCRYPTION_FERNET_PRIMARY_KEY_ID"),
802+
"fernet_keys_location": os.getenv("DATABASE_ENCRYPTION_FERNET_KEYS_LOCATION"),
807803
}
808804

805+
809806
#######################
810807
# Taskworker settings #
811808
#######################
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Literal, TypedDict
2+
3+
4+
class EncryptedFieldSettings(TypedDict):
5+
method: Literal["fernet"] | Literal["plaintext"]
6+
# fernet config
7+
fernet_primary_key_id: str | None
8+
fernet_keys_location: str | None

src/sentry/db/models/fields/encryption.py

Lines changed: 7 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
from typing import Any, Literal, TypedDict
77

88
import sentry_sdk
9-
from cryptography.fernet import Fernet, InvalidToken
10-
from django.conf import settings
9+
from cryptography.fernet import InvalidToken
1110
from django.db.models import CharField, Field
1211

1312
from sentry import options
13+
from sentry.utils.security.encrypted_field_key_store import FernetKeyStore
1414

1515
logger = logging.getLogger(__name__)
1616

@@ -102,8 +102,10 @@ def get_prep_value(self, value: Any) -> str | None:
102102
if value is None:
103103
return value
104104

105+
# Get the encryption method from the options
106+
# xxx(vgrozdanic): this is a temporary solution during a rollout
107+
# so that we can quickly rollback if needed.
105108
encryption_method = options.get("database.encryption.method")
106-
107109
# Default to plaintext if method is not recognized
108110
if encryption_method not in self._encryption_handlers:
109111
logger.error(
@@ -179,15 +181,8 @@ def _encrypt_fernet(self, value: Any) -> str:
179181
Always returns formatted string: enc:fernet:key_id:base64data
180182
The key_id is required to support key rotation.
181183
"""
182-
key_id, key = self._get_fernet_key_for_encryption()
183-
if not key:
184-
raise ValueError(
185-
"Fernet encryption key is required but not found. "
186-
"Please set DATABASE_ENCRYPTION_FERNET_KEYS in your settings."
187-
)
188-
189184
try:
190-
f = Fernet(key)
185+
key_id, f = FernetKeyStore.get_primary_fernet()
191186
value_bytes = self._get_value_in_bytes(value)
192187
encrypted_data = f.encrypt(value_bytes)
193188
return self._format_encrypted_value(encrypted_data, MARKER_FERNET, key_id)
@@ -215,14 +210,8 @@ def _decrypt_fernet(self, value: str) -> bytes:
215210
logger.warning("Failed to decode base64 data: %s", e)
216211
raise ValueError("Invalid base64 encoding") from e
217212

218-
# Get the decryption key
219-
key = self._get_fernet_key(key_id)
220-
if not key:
221-
logger.warning("No decryption key found for key_id '%s'", key_id)
222-
raise ValueError(f"Cannot decrypt without key for key_id '{key_id}'")
223-
224213
try:
225-
f = Fernet(key)
214+
f = FernetKeyStore.get_fernet_for_key_id(key_id)
226215
decrypted = f.decrypt(encrypted_data)
227216
return decrypted
228217
except InvalidToken: # noqa
@@ -280,70 +269,6 @@ def _decrypt_with_fallback(self, value: str) -> bytes | str:
280269
logger.warning("No handler found for marker '%s'", marker)
281270
return value
282271

283-
def _get_fernet_key_for_encryption(self) -> tuple[str, bytes]:
284-
"""Get the first Fernet key for encryption along with its key_id."""
285-
keys = getattr(settings, "DATABASE_ENCRYPTION_FERNET_KEYS", None)
286-
if keys is None:
287-
raise ValueError("DATABASE_ENCRYPTION_FERNET_KEYS is not configured")
288-
289-
if not isinstance(keys, dict):
290-
logger.error("DATABASE_ENCRYPTION_FERNET_KEYS must be a dict, got %s", type(keys))
291-
raise ValueError("DATABASE_ENCRYPTION_FERNET_KEYS must be a dictionary")
292-
293-
if not keys:
294-
raise ValueError("DATABASE_ENCRYPTION_FERNET_KEYS is empty")
295-
296-
# Use the first key for encryption
297-
key_id = next(iter(keys.keys()))
298-
key = keys[key_id]
299-
300-
if not key_id or not key:
301-
raise ValueError(
302-
f"DATABASE_ENCRYPTION_FERNET_KEYS has invalid key_id or key ({key_id}, {key})"
303-
)
304-
305-
if isinstance(key, str):
306-
key = key.encode("utf-8")
307-
308-
# Validate key
309-
try:
310-
Fernet(key)
311-
return (key_id, key)
312-
except Exception as e:
313-
sentry_sdk.capture_exception(e)
314-
logger.exception("Invalid Fernet key for key_id '%s'", key_id)
315-
raise ValueError(f"Invalid Fernet key for key_id '{key_id}'")
316-
317-
def _get_fernet_key(self, key_id: str) -> bytes | None:
318-
"""Get the Fernet key for the specified key_id from Django settings."""
319-
keys = getattr(settings, "DATABASE_ENCRYPTION_FERNET_KEYS", None)
320-
if keys is None:
321-
return None
322-
323-
if not isinstance(keys, dict):
324-
logger.error("DATABASE_ENCRYPTION_FERNET_KEYS must be a dict, got %s", type(keys))
325-
return None
326-
327-
# Return specific key by key_id
328-
if key_id not in keys:
329-
logger.warning("Fernet key with id '%s' not found, cannot decrypt data", key_id)
330-
return None
331-
key = keys[key_id]
332-
333-
if isinstance(key, str):
334-
# If key is a string, encode it
335-
key = key.encode("utf-8")
336-
337-
# Validate key length (Fernet requires 32 bytes base64 encoded)
338-
try:
339-
Fernet(key)
340-
return key
341-
except Exception as e:
342-
sentry_sdk.capture_exception(e)
343-
logger.exception("Invalid Fernet key")
344-
345-
return None
346-
347272

348273
class EncryptedCharField(EncryptedField, CharField):
349274
def from_db_value(self, value: Any, expression: Any, connection: Any) -> Any:
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from logging import getLogger
2+
from pathlib import Path
3+
4+
import sentry_sdk
5+
from cryptography.fernet import Fernet
6+
from django.conf import settings
7+
from django.core.exceptions import ImproperlyConfigured
8+
9+
logger = getLogger(__name__)
10+
11+
12+
class FernetKeyStore:
13+
_keys: dict[str, Fernet] | None = {}
14+
_is_loaded = False
15+
16+
@classmethod
17+
def _path_to_keys(cls) -> Path | None:
18+
settings_path = settings.DATABASE_ENCRYPTION_SETTINGS.get("fernet_keys_location")
19+
20+
return Path(settings_path) if settings_path is not None else None
21+
22+
@classmethod
23+
def load_keys(cls) -> None:
24+
"""
25+
Reads all files in the given directory.
26+
Filename = Key ID
27+
File Content = Fernet Key
28+
"""
29+
path = cls._path_to_keys()
30+
31+
if path is None:
32+
# No keys directory is configured, so we don't need to load any keys.
33+
cls._keys = None
34+
cls._is_loaded = True
35+
return
36+
37+
if not path.exists() or not path.is_dir():
38+
raise ImproperlyConfigured(f"Key directory not found: {path}")
39+
40+
# Clear the keys dictionary to avoid stale data
41+
cls._keys = {}
42+
43+
for file_path in path.iterdir():
44+
if file_path.is_file():
45+
# Skip hidden files
46+
if file_path.name.startswith("."):
47+
continue
48+
49+
try:
50+
with open(file_path) as f:
51+
key_content = f.read().strip()
52+
53+
if not key_content:
54+
logger.warning("Empty key file found: %s", file_path.name)
55+
continue
56+
57+
# Store Fernet object in the dictionary
58+
# Objects are stored instead of keys for performance optimization.
59+
cls._keys[file_path.name] = Fernet(key_content.encode("utf-8"))
60+
61+
except Exception as e:
62+
logger.exception("Error reading key %s", file_path.name)
63+
sentry_sdk.capture_exception(e)
64+
65+
cls._is_loaded = True
66+
logger.info("Successfully loaded %d Fernet encryption keys.", len(cls._keys))
67+
68+
@classmethod
69+
def get_fernet_for_key_id(cls, key_id: str) -> Fernet:
70+
"""Retrieves a Fernet object for a specific key ID"""
71+
if not cls._is_loaded:
72+
# Fallback: if the keys are not already loaded, load them on first access.
73+
logger.warning("Loading Fernet encryption keys on first access instead of on startup.")
74+
cls.load_keys()
75+
76+
if cls._keys is None:
77+
# This can happen only if the keys settings are misconfigured
78+
raise ValueError(
79+
"Fernet encryption keys are not loaded. Please configure the keys directory."
80+
)
81+
82+
fernet = cls._keys.get(key_id)
83+
if not fernet:
84+
raise ValueError(f"Encryption key with ID '{key_id}' not found.")
85+
86+
return fernet
87+
88+
@classmethod
89+
def get_primary_fernet(cls) -> tuple[str, Fernet]:
90+
"""
91+
Reads the configuration and returns the primary key ID and the Fernet object
92+
initialized with the primary Fernet key.
93+
94+
The primary Fernet is the one that is used to encrypt the data, while
95+
decryption can be done with any of the registered Fernet keys.
96+
"""
97+
98+
primary_key_id = settings.DATABASE_ENCRYPTION_SETTINGS.get("fernet_primary_key_id")
99+
if primary_key_id is None:
100+
raise ValueError("Fernet primary key ID is not configured.")
101+
102+
return primary_key_id, cls.get_fernet_for_key_id(primary_key_id)

0 commit comments

Comments
 (0)