mdb-engine 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdb_engine/__init__.py +104 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +648 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +264 -69
- mdb_engine/auth/config_helpers.py +7 -6
- mdb_engine/auth/cookie_utils.py +3 -7
- mdb_engine/auth/csrf.py +373 -0
- mdb_engine/auth/decorators.py +3 -10
- mdb_engine/auth/dependencies.py +47 -50
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +53 -80
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +18 -38
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +504 -0
- mdb_engine/auth/restrictions.py +8 -24
- mdb_engine/auth/session_manager.py +14 -29
- mdb_engine/auth/shared_middleware.py +600 -0
- mdb_engine/auth/shared_users.py +759 -0
- mdb_engine/auth/token_store.py +14 -28
- mdb_engine/auth/users.py +54 -113
- mdb_engine/auth/utils.py +213 -15
- mdb_engine/cli/commands/generate.py +545 -9
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +3 -3
- mdb_engine/config.py +7 -21
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +22 -41
- mdb_engine/core/app_secrets.py +290 -0
- mdb_engine/core/connection.py +18 -9
- mdb_engine/core/encryption.py +223 -0
- mdb_engine/core/engine.py +1057 -93
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +459 -150
- mdb_engine/core/ray_integration.py +435 -0
- mdb_engine/core/seeding.py +10 -18
- mdb_engine/core/service_initialization.py +12 -23
- mdb_engine/core/types.py +2 -5
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +25 -37
- mdb_engine/database/connection.py +11 -18
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +713 -196
- mdb_engine/dependencies.py +426 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +248 -0
- mdb_engine/di/providers.py +205 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +37 -154
- mdb_engine/embeddings/service.py +11 -25
- mdb_engine/exceptions.py +92 -0
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +1 -1
- mdb_engine/indexes/manager.py +50 -114
- mdb_engine/memory/README.md +2 -2
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +30 -87
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +8 -9
- mdb_engine/observability/metrics.py +32 -12
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +25 -60
- mdb_engine-0.2.0.dist-info/METADATA +313 -0
- mdb_engine-0.2.0.dist-info/RECORD +96 -0
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
App Secrets Manager
|
|
3
|
+
|
|
4
|
+
Manages encrypted app secrets stored in MongoDB using envelope encryption.
|
|
5
|
+
|
|
6
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
7
|
+
|
|
8
|
+
The AppSecretsManager stores encrypted app secrets in the `_mdb_engine_app_secrets`
|
|
9
|
+
collection, which is only accessible via raw MongoDB client (not scoped wrapper).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import logging
|
|
14
|
+
import secrets
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
19
|
+
from pymongo.errors import OperationFailure, PyMongoError
|
|
20
|
+
|
|
21
|
+
from .encryption import EnvelopeEncryptionService
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Collection name for storing encrypted app secrets
|
|
26
|
+
SECRETS_COLLECTION_NAME = "_mdb_engine_app_secrets"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AppSecretsManager:
|
|
30
|
+
"""
|
|
31
|
+
Manages encrypted app secrets using envelope encryption.
|
|
32
|
+
|
|
33
|
+
Secrets are stored encrypted in MongoDB and can only be verified,
|
|
34
|
+
not retrieved in plaintext (except during rotation).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
mongo_db: AsyncIOMotorDatabase,
|
|
40
|
+
encryption_service: EnvelopeEncryptionService,
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Initialize the app secrets manager.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
mongo_db: MongoDB database instance (raw, not scoped)
|
|
47
|
+
encryption_service: Envelope encryption service instance
|
|
48
|
+
"""
|
|
49
|
+
self._mongo_db = mongo_db
|
|
50
|
+
self._encryption_service = encryption_service
|
|
51
|
+
self._secrets_collection = mongo_db[SECRETS_COLLECTION_NAME]
|
|
52
|
+
|
|
53
|
+
async def store_app_secret(self, app_slug: str, secret: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Store an encrypted app secret.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
app_slug: App slug identifier
|
|
59
|
+
secret: Plaintext secret to encrypt and store
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
OperationFailure: If MongoDB operation fails
|
|
63
|
+
|
|
64
|
+
The secret is encrypted using envelope encryption and stored with
|
|
65
|
+
metadata (created_at, updated_at, rotation_count).
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# Encrypt secret
|
|
69
|
+
encrypted_secret, encrypted_dek = self._encryption_service.encrypt_secret(secret)
|
|
70
|
+
|
|
71
|
+
# Encode as base64 for storage
|
|
72
|
+
encrypted_secret_b64 = base64.b64encode(encrypted_secret).decode()
|
|
73
|
+
encrypted_dek_b64 = base64.b64encode(encrypted_dek).decode()
|
|
74
|
+
|
|
75
|
+
# Prepare document
|
|
76
|
+
now = datetime.utcnow()
|
|
77
|
+
document = {
|
|
78
|
+
"_id": app_slug,
|
|
79
|
+
"encrypted_secret": encrypted_secret_b64,
|
|
80
|
+
"encrypted_dek": encrypted_dek_b64,
|
|
81
|
+
"algorithm": "AES-256-GCM",
|
|
82
|
+
"updated_at": now,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Check if secret already exists
|
|
86
|
+
existing = await self._secrets_collection.find_one({"_id": app_slug})
|
|
87
|
+
if existing:
|
|
88
|
+
# Update existing secret
|
|
89
|
+
document["created_at"] = existing.get("created_at", now)
|
|
90
|
+
document["rotation_count"] = existing.get("rotation_count", 0) + 1
|
|
91
|
+
await self._secrets_collection.replace_one({"_id": app_slug}, document)
|
|
92
|
+
logger.info(
|
|
93
|
+
f"Updated encrypted secret for app '{app_slug}' "
|
|
94
|
+
f"(rotation #{document['rotation_count']})"
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
# Insert new secret
|
|
98
|
+
document["created_at"] = now
|
|
99
|
+
document["rotation_count"] = 0
|
|
100
|
+
await self._secrets_collection.insert_one(document)
|
|
101
|
+
logger.info(f"Stored encrypted secret for app '{app_slug}'")
|
|
102
|
+
|
|
103
|
+
except PyMongoError as e:
|
|
104
|
+
logger.error(f"Database error storing secret for app '{app_slug}': {e}", exc_info=True)
|
|
105
|
+
raise OperationFailure(f"Failed to store app secret: {e}") from e
|
|
106
|
+
except (ValueError, TypeError) as e:
|
|
107
|
+
logger.error(
|
|
108
|
+
f"Encryption error storing secret for app '{app_slug}': {e}", exc_info=True
|
|
109
|
+
)
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
def verify_app_secret_sync(self, app_slug: str, provided_secret: str) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Synchronous version of verify_app_secret for use in sync contexts.
|
|
115
|
+
|
|
116
|
+
Note: This uses asyncio.run() which creates a new event loop.
|
|
117
|
+
Use verify_app_secret() in async contexts for better performance.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
app_slug: App slug identifier
|
|
121
|
+
provided_secret: Secret to verify
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if secret matches, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
import asyncio
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# Try to get running loop
|
|
130
|
+
asyncio.get_running_loop()
|
|
131
|
+
# If we're in an async context, we can't use asyncio.run()
|
|
132
|
+
# Return False and log warning
|
|
133
|
+
logger.warning(
|
|
134
|
+
f"Cannot verify app secret synchronously in async context "
|
|
135
|
+
f"for '{app_slug}'. Use verify_app_secret() instead."
|
|
136
|
+
)
|
|
137
|
+
return False
|
|
138
|
+
except RuntimeError:
|
|
139
|
+
# No running loop - safe to use asyncio.run()
|
|
140
|
+
return asyncio.run(self.verify_app_secret(app_slug, provided_secret))
|
|
141
|
+
|
|
142
|
+
async def verify_app_secret(self, app_slug: str, provided_secret: str) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Verify an app secret against stored encrypted value.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
app_slug: App slug identifier
|
|
148
|
+
provided_secret: Secret to verify
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if secret matches, False otherwise
|
|
152
|
+
|
|
153
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
154
|
+
"""
|
|
155
|
+
# Get encrypted secret from database
|
|
156
|
+
doc = await self._secrets_collection.find_one({"_id": app_slug})
|
|
157
|
+
if not doc:
|
|
158
|
+
# Log detailed info for debugging
|
|
159
|
+
logger.warning(f"Secret verification failed: app '{app_slug}' not found")
|
|
160
|
+
# Return False without revealing app existence
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
# Decode base64
|
|
164
|
+
try:
|
|
165
|
+
encrypted_secret = base64.b64decode(doc["encrypted_secret"])
|
|
166
|
+
encrypted_dek = base64.b64decode(doc["encrypted_dek"])
|
|
167
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
168
|
+
# Log detailed error for debugging
|
|
169
|
+
logger.warning(f"Secret verification error for app '{app_slug}': {e}", exc_info=True)
|
|
170
|
+
# Return False without revealing specific error
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# Decrypt stored secret
|
|
174
|
+
try:
|
|
175
|
+
stored_secret = self._encryption_service.decrypt_secret(encrypted_secret, encrypted_dek)
|
|
176
|
+
except ValueError:
|
|
177
|
+
# Log detailed error for debugging
|
|
178
|
+
logger.warning(f"Secret decryption failed for app '{app_slug}'", exc_info=True)
|
|
179
|
+
# Return False without revealing decryption failure
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
# Use secrets.compare_digest for constant-time comparison
|
|
183
|
+
# Note: compare_digest handles length differences internally, preventing timing attacks
|
|
184
|
+
result = secrets.compare_digest(provided_secret.encode(), stored_secret.encode())
|
|
185
|
+
if result:
|
|
186
|
+
logger.debug(f"Secret verification succeeded for app '{app_slug}'")
|
|
187
|
+
else:
|
|
188
|
+
logger.warning(f"Secret verification failed for app '{app_slug}'")
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
async def get_app_secret(self, app_slug: str) -> Optional[str]:
|
|
192
|
+
"""
|
|
193
|
+
Get decrypted app secret (for rotation purposes only).
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
app_slug: App slug identifier
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Decrypted secret if found, None otherwise
|
|
200
|
+
|
|
201
|
+
Warning:
|
|
202
|
+
This method returns plaintext secrets. Use only for rotation.
|
|
203
|
+
Regular verification should use verify_app_secret().
|
|
204
|
+
"""
|
|
205
|
+
doc = await self._secrets_collection.find_one({"_id": app_slug})
|
|
206
|
+
if not doc:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# Decode base64
|
|
210
|
+
try:
|
|
211
|
+
encrypted_secret = base64.b64decode(doc["encrypted_secret"])
|
|
212
|
+
encrypted_dek = base64.b64decode(doc["encrypted_dek"])
|
|
213
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
214
|
+
logger.warning(f"Failed to decode secret for app '{app_slug}': {e}")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# Decrypt secret
|
|
218
|
+
try:
|
|
219
|
+
secret = self._encryption_service.decrypt_secret(encrypted_secret, encrypted_dek)
|
|
220
|
+
return secret
|
|
221
|
+
except ValueError as e:
|
|
222
|
+
logger.warning(f"Failed to decrypt secret for app '{app_slug}': {e}")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
async def rotate_app_secret(self, app_slug: str) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Rotate an app secret (generate new secret, re-encrypt and store).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
app_slug: App slug identifier
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
New plaintext secret (caller must store securely)
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If app secret not found
|
|
237
|
+
|
|
238
|
+
The new secret is encrypted and stored, replacing the old one.
|
|
239
|
+
"""
|
|
240
|
+
# Generate new secret
|
|
241
|
+
new_secret = secrets.token_urlsafe(32)
|
|
242
|
+
|
|
243
|
+
# Store new encrypted secret
|
|
244
|
+
await self.store_app_secret(app_slug, new_secret)
|
|
245
|
+
|
|
246
|
+
logger.info(f"Rotated secret for app '{app_slug}'")
|
|
247
|
+
return new_secret
|
|
248
|
+
|
|
249
|
+
def app_secret_exists_sync(self, app_slug: str) -> bool:
|
|
250
|
+
"""
|
|
251
|
+
Synchronous version of app_secret_exists for use in sync contexts.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
app_slug: App slug identifier
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
True if secret exists, False otherwise
|
|
258
|
+
|
|
259
|
+
Note: If called from an async context, this will return False and log a warning.
|
|
260
|
+
Use app_secret_exists() in async contexts.
|
|
261
|
+
"""
|
|
262
|
+
import asyncio
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Try to get running loop
|
|
266
|
+
asyncio.get_running_loop()
|
|
267
|
+
# We're in an async context - can't safely check without blocking
|
|
268
|
+
# Return False (assume no secret) to allow backward compatibility
|
|
269
|
+
# The secret will be verified at query time through async methods
|
|
270
|
+
logger.debug(
|
|
271
|
+
f"Cannot check app secret existence synchronously in async context "
|
|
272
|
+
f"for '{app_slug}'. Assuming no secret exists (backward compatibility)."
|
|
273
|
+
)
|
|
274
|
+
return False
|
|
275
|
+
except RuntimeError:
|
|
276
|
+
# No running loop - safe to use asyncio.run()
|
|
277
|
+
return asyncio.run(self.app_secret_exists(app_slug))
|
|
278
|
+
|
|
279
|
+
async def app_secret_exists(self, app_slug: str) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Check if an app secret exists.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
app_slug: App slug identifier
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
True if secret exists, False otherwise
|
|
288
|
+
"""
|
|
289
|
+
doc = await self._secrets_collection.find_one({"_id": app_slug}, projection={"_id": 1})
|
|
290
|
+
return doc is not None
|
mdb_engine/core/connection.py
CHANGED
|
@@ -14,9 +14,12 @@ from typing import Optional
|
|
|
14
14
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
|
15
15
|
from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError
|
|
16
16
|
|
|
17
|
-
from ..constants import (
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
from ..constants import (
|
|
18
|
+
DEFAULT_MAX_IDLE_TIME_MS,
|
|
19
|
+
DEFAULT_MAX_POOL_SIZE,
|
|
20
|
+
DEFAULT_MIN_POOL_SIZE,
|
|
21
|
+
DEFAULT_SERVER_SELECTION_TIMEOUT_MS,
|
|
22
|
+
)
|
|
20
23
|
from ..exceptions import InitializationError
|
|
21
24
|
from ..observability import get_logger as get_contextual_logger
|
|
22
25
|
from ..observability import record_operation
|
|
@@ -73,9 +76,7 @@ class ConnectionManager:
|
|
|
73
76
|
start_time = time.time()
|
|
74
77
|
|
|
75
78
|
if self._initialized:
|
|
76
|
-
logger.warning(
|
|
77
|
-
"ConnectionManager already initialized. Skipping re-initialization."
|
|
78
|
-
)
|
|
79
|
+
logger.warning("ConnectionManager already initialized. Skipping re-initialization.")
|
|
79
80
|
return
|
|
80
81
|
|
|
81
82
|
contextual_logger.info(
|
|
@@ -232,11 +233,19 @@ class ConnectionManager:
|
|
|
232
233
|
raise RuntimeError(
|
|
233
234
|
"ConnectionManager not initialized. Call initialize() first.",
|
|
234
235
|
)
|
|
235
|
-
|
|
236
|
-
self._mongo_db is not None
|
|
237
|
-
), "MongoDB database should not be None after initialization"
|
|
236
|
+
# Allow None for testing scenarios where mongo_db is patched
|
|
238
237
|
return self._mongo_db
|
|
239
238
|
|
|
239
|
+
@mongo_db.deleter
|
|
240
|
+
def mongo_db(self) -> None:
|
|
241
|
+
"""Allow deletion of mongo_db property for testing purposes."""
|
|
242
|
+
self._mongo_db = None
|
|
243
|
+
|
|
244
|
+
@mongo_db.setter
|
|
245
|
+
def mongo_db(self, value: Optional[AsyncIOMotorDatabase]) -> None:
|
|
246
|
+
"""Allow setting mongo_db property for testing purposes."""
|
|
247
|
+
self._mongo_db = value
|
|
248
|
+
|
|
240
249
|
@property
|
|
241
250
|
def initialized(self) -> bool:
|
|
242
251
|
"""Check if connection is initialized."""
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Envelope Encryption Service
|
|
3
|
+
|
|
4
|
+
Provides envelope encryption for app secrets using a master key and
|
|
5
|
+
per-secret data encryption keys (DEKs).
|
|
6
|
+
|
|
7
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
8
|
+
|
|
9
|
+
Envelope Encryption Model:
|
|
10
|
+
- Master Key (MK): Encrypts DEKs, stored in environment variable
|
|
11
|
+
- Data Encryption Key (DEK): Encrypts app secrets, encrypted with MK
|
|
12
|
+
- App Secret: Plaintext secret, encrypted with DEK
|
|
13
|
+
|
|
14
|
+
This provides defense-in-depth: even if DEKs are compromised, they
|
|
15
|
+
cannot be decrypted without the master key.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
from typing import Tuple
|
|
23
|
+
|
|
24
|
+
import cryptography.exceptions
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Master key environment variable name
|
|
30
|
+
MASTER_KEY_ENV_VAR = "MDB_ENGINE_MASTER_KEY"
|
|
31
|
+
|
|
32
|
+
# AES-GCM configuration
|
|
33
|
+
AES_KEY_SIZE = 32 # 256 bits
|
|
34
|
+
AES_NONCE_SIZE = 12 # 96 bits for GCM
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EnvelopeEncryptionService:
|
|
38
|
+
"""
|
|
39
|
+
Service for envelope encryption of app secrets.
|
|
40
|
+
|
|
41
|
+
Uses AES-256-GCM for encryption with envelope encryption pattern:
|
|
42
|
+
1. Generate random DEK for each secret
|
|
43
|
+
2. Encrypt secret with DEK
|
|
44
|
+
3. Encrypt DEK with master key
|
|
45
|
+
4. Store both encrypted values
|
|
46
|
+
|
|
47
|
+
This allows master key rotation without re-encrypting all secrets.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, master_key: bytes | None = None):
|
|
51
|
+
"""
|
|
52
|
+
Initialize the encryption service.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
master_key: Master key bytes. If None, loads from environment.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If master key is not provided and not in environment.
|
|
59
|
+
"""
|
|
60
|
+
self._master_key = master_key or self._load_master_key()
|
|
61
|
+
if not self._master_key:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Master key not found. Set {MASTER_KEY_ENV_VAR} environment variable "
|
|
64
|
+
"or provide master_key parameter."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _load_master_key(self) -> bytes | None:
|
|
68
|
+
"""
|
|
69
|
+
Load master key from environment variable.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Master key bytes if found, None otherwise.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If master key format is invalid.
|
|
76
|
+
"""
|
|
77
|
+
master_key_str = os.getenv(MASTER_KEY_ENV_VAR)
|
|
78
|
+
if not master_key_str:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Decode base64-encoded master key
|
|
83
|
+
master_key_bytes = base64.b64decode(master_key_str.encode())
|
|
84
|
+
if len(master_key_bytes) != AES_KEY_SIZE:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Master key must be {AES_KEY_SIZE} bytes (256 bits) when decoded. "
|
|
87
|
+
f"Got {len(master_key_bytes)} bytes."
|
|
88
|
+
)
|
|
89
|
+
return master_key_bytes
|
|
90
|
+
except (ValueError, TypeError, UnicodeDecodeError) as e:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid master key format in {MASTER_KEY_ENV_VAR}. "
|
|
93
|
+
f"Expected base64-encoded {AES_KEY_SIZE}-byte key. Error: {e}"
|
|
94
|
+
) from e
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def generate_master_key() -> str:
|
|
98
|
+
"""
|
|
99
|
+
Generate a new master key.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Base64-encoded master key string (suitable for environment variable).
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> key = EnvelopeEncryptionService.generate_master_key()
|
|
106
|
+
>>> # Store in .env: MDB_ENGINE_MASTER_KEY={key}
|
|
107
|
+
"""
|
|
108
|
+
key_bytes = secrets.token_bytes(AES_KEY_SIZE)
|
|
109
|
+
return base64.b64encode(key_bytes).decode()
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def generate_dek() -> bytes:
|
|
113
|
+
"""
|
|
114
|
+
Generate a random Data Encryption Key (DEK).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Random 32-byte DEK.
|
|
118
|
+
|
|
119
|
+
Note:
|
|
120
|
+
Each secret gets its own DEK for better security isolation.
|
|
121
|
+
"""
|
|
122
|
+
return secrets.token_bytes(AES_KEY_SIZE)
|
|
123
|
+
|
|
124
|
+
def encrypt_secret(self, secret: str, master_key: bytes | None = None) -> Tuple[bytes, bytes]:
|
|
125
|
+
"""
|
|
126
|
+
Encrypt a secret using envelope encryption.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
secret: Plaintext secret to encrypt.
|
|
130
|
+
master_key: Master key to use. If None, uses instance master key.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (encrypted_secret, encrypted_dek) as bytes.
|
|
134
|
+
|
|
135
|
+
Process:
|
|
136
|
+
1. Generate random DEK
|
|
137
|
+
2. Encrypt secret with DEK using AES-GCM
|
|
138
|
+
3. Encrypt DEK with master key using AES-GCM
|
|
139
|
+
4. Return both encrypted values
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> service = EnvelopeEncryptionService()
|
|
143
|
+
>>> encrypted_secret, encrypted_dek = service.encrypt_secret("my_secret")
|
|
144
|
+
"""
|
|
145
|
+
master_key = master_key or self._master_key
|
|
146
|
+
|
|
147
|
+
# Generate random DEK
|
|
148
|
+
dek = self.generate_dek()
|
|
149
|
+
|
|
150
|
+
# Encrypt secret with DEK
|
|
151
|
+
aesgcm_secret = AESGCM(dek)
|
|
152
|
+
nonce_secret = secrets.token_bytes(AES_NONCE_SIZE)
|
|
153
|
+
encrypted_secret = aesgcm_secret.encrypt(nonce_secret, secret.encode(), None)
|
|
154
|
+
|
|
155
|
+
# Prepend nonce to encrypted secret
|
|
156
|
+
encrypted_secret_with_nonce = nonce_secret + encrypted_secret
|
|
157
|
+
|
|
158
|
+
# Encrypt DEK with master key
|
|
159
|
+
aesgcm_dek = AESGCM(master_key)
|
|
160
|
+
nonce_dek = secrets.token_bytes(AES_NONCE_SIZE)
|
|
161
|
+
encrypted_dek = aesgcm_dek.encrypt(nonce_dek, dek, None)
|
|
162
|
+
|
|
163
|
+
# Prepend nonce to encrypted DEK
|
|
164
|
+
encrypted_dek_with_nonce = nonce_dek + encrypted_dek
|
|
165
|
+
|
|
166
|
+
return encrypted_secret_with_nonce, encrypted_dek_with_nonce
|
|
167
|
+
|
|
168
|
+
def decrypt_secret(
|
|
169
|
+
self,
|
|
170
|
+
encrypted_secret: bytes,
|
|
171
|
+
encrypted_dek: bytes,
|
|
172
|
+
master_key: bytes | None = None,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Decrypt a secret using envelope decryption.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
encrypted_secret: Encrypted secret (with nonce prepended).
|
|
179
|
+
encrypted_dek: Encrypted DEK (with nonce prepended).
|
|
180
|
+
master_key: Master key to use. If None, uses instance master key.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Decrypted plaintext secret.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If decryption fails (invalid key, corrupted data, etc.).
|
|
187
|
+
|
|
188
|
+
Process:
|
|
189
|
+
1. Decrypt DEK with master key
|
|
190
|
+
2. Decrypt secret with DEK
|
|
191
|
+
3. Return plaintext secret
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
>>> service = EnvelopeEncryptionService()
|
|
195
|
+
>>> secret = service.decrypt_secret(encrypted_secret, encrypted_dek)
|
|
196
|
+
"""
|
|
197
|
+
master_key = master_key or self._master_key
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
# Extract nonce and encrypted data for DEK
|
|
201
|
+
if len(encrypted_dek) < AES_NONCE_SIZE:
|
|
202
|
+
raise ValueError("Encrypted DEK too short (missing nonce)")
|
|
203
|
+
nonce_dek = encrypted_dek[:AES_NONCE_SIZE]
|
|
204
|
+
encrypted_dek_data = encrypted_dek[AES_NONCE_SIZE:]
|
|
205
|
+
|
|
206
|
+
# Decrypt DEK with master key
|
|
207
|
+
aesgcm_dek = AESGCM(master_key)
|
|
208
|
+
dek = aesgcm_dek.decrypt(nonce_dek, encrypted_dek_data, None)
|
|
209
|
+
|
|
210
|
+
# Extract nonce and encrypted data for secret
|
|
211
|
+
if len(encrypted_secret) < AES_NONCE_SIZE:
|
|
212
|
+
raise ValueError("Encrypted secret too short (missing nonce)")
|
|
213
|
+
nonce_secret = encrypted_secret[:AES_NONCE_SIZE]
|
|
214
|
+
encrypted_secret_data = encrypted_secret[AES_NONCE_SIZE:]
|
|
215
|
+
|
|
216
|
+
# Decrypt secret with DEK
|
|
217
|
+
aesgcm_secret = AESGCM(dek)
|
|
218
|
+
secret_bytes = aesgcm_secret.decrypt(nonce_secret, encrypted_secret_data, None)
|
|
219
|
+
|
|
220
|
+
return secret_bytes.decode()
|
|
221
|
+
except (ValueError, TypeError, UnicodeDecodeError, cryptography.exceptions.InvalidTag) as e:
|
|
222
|
+
logger.warning(f"Decryption failed: {e}")
|
|
223
|
+
raise ValueError(f"Failed to decrypt secret: {e}") from e
|