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