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.
Files changed (87) hide show
  1. mdb_engine/__init__.py +104 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +648 -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 +264 -69
  8. mdb_engine/auth/config_helpers.py +7 -6
  9. mdb_engine/auth/cookie_utils.py +3 -7
  10. mdb_engine/auth/csrf.py +373 -0
  11. mdb_engine/auth/decorators.py +3 -10
  12. mdb_engine/auth/dependencies.py +47 -50
  13. mdb_engine/auth/helpers.py +3 -3
  14. mdb_engine/auth/integration.py +53 -80
  15. mdb_engine/auth/jwt.py +2 -6
  16. mdb_engine/auth/middleware.py +77 -34
  17. mdb_engine/auth/oso_factory.py +18 -38
  18. mdb_engine/auth/provider.py +270 -171
  19. mdb_engine/auth/rate_limiter.py +504 -0
  20. mdb_engine/auth/restrictions.py +8 -24
  21. mdb_engine/auth/session_manager.py +14 -29
  22. mdb_engine/auth/shared_middleware.py +600 -0
  23. mdb_engine/auth/shared_users.py +759 -0
  24. mdb_engine/auth/token_store.py +14 -28
  25. mdb_engine/auth/users.py +54 -113
  26. mdb_engine/auth/utils.py +213 -15
  27. mdb_engine/cli/commands/generate.py +545 -9
  28. mdb_engine/cli/commands/validate.py +3 -7
  29. mdb_engine/cli/utils.py +3 -3
  30. mdb_engine/config.py +7 -21
  31. mdb_engine/constants.py +65 -0
  32. mdb_engine/core/README.md +117 -6
  33. mdb_engine/core/__init__.py +39 -7
  34. mdb_engine/core/app_registration.py +22 -41
  35. mdb_engine/core/app_secrets.py +290 -0
  36. mdb_engine/core/connection.py +18 -9
  37. mdb_engine/core/encryption.py +223 -0
  38. mdb_engine/core/engine.py +1057 -93
  39. mdb_engine/core/index_management.py +12 -16
  40. mdb_engine/core/manifest.py +459 -150
  41. mdb_engine/core/ray_integration.py +435 -0
  42. mdb_engine/core/seeding.py +10 -18
  43. mdb_engine/core/service_initialization.py +12 -23
  44. mdb_engine/core/types.py +2 -5
  45. mdb_engine/database/README.md +140 -17
  46. mdb_engine/database/__init__.py +17 -6
  47. mdb_engine/database/abstraction.py +25 -37
  48. mdb_engine/database/connection.py +11 -18
  49. mdb_engine/database/query_validator.py +367 -0
  50. mdb_engine/database/resource_limiter.py +204 -0
  51. mdb_engine/database/scoped_wrapper.py +713 -196
  52. mdb_engine/dependencies.py +426 -0
  53. mdb_engine/di/__init__.py +34 -0
  54. mdb_engine/di/container.py +248 -0
  55. mdb_engine/di/providers.py +205 -0
  56. mdb_engine/di/scopes.py +139 -0
  57. mdb_engine/embeddings/README.md +54 -24
  58. mdb_engine/embeddings/__init__.py +31 -24
  59. mdb_engine/embeddings/dependencies.py +37 -154
  60. mdb_engine/embeddings/service.py +11 -25
  61. mdb_engine/exceptions.py +92 -0
  62. mdb_engine/indexes/README.md +30 -13
  63. mdb_engine/indexes/__init__.py +1 -0
  64. mdb_engine/indexes/helpers.py +1 -1
  65. mdb_engine/indexes/manager.py +50 -114
  66. mdb_engine/memory/README.md +2 -2
  67. mdb_engine/memory/__init__.py +1 -2
  68. mdb_engine/memory/service.py +30 -87
  69. mdb_engine/observability/README.md +4 -2
  70. mdb_engine/observability/__init__.py +26 -9
  71. mdb_engine/observability/health.py +8 -9
  72. mdb_engine/observability/metrics.py +32 -12
  73. mdb_engine/repositories/__init__.py +34 -0
  74. mdb_engine/repositories/base.py +325 -0
  75. mdb_engine/repositories/mongo.py +233 -0
  76. mdb_engine/repositories/unit_of_work.py +166 -0
  77. mdb_engine/routing/README.md +1 -1
  78. mdb_engine/routing/__init__.py +1 -3
  79. mdb_engine/routing/websockets.py +25 -60
  80. mdb_engine-0.2.0.dist-info/METADATA +313 -0
  81. mdb_engine-0.2.0.dist-info/RECORD +96 -0
  82. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  83. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  84. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
  85. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
  86. {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
  87. {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
@@ -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 (DEFAULT_MAX_IDLE_TIME_MS, DEFAULT_MAX_POOL_SIZE,
18
- DEFAULT_MIN_POOL_SIZE,
19
- DEFAULT_SERVER_SELECTION_TIMEOUT_MS)
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
- assert (
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