mcp-security-framework 2.1.0__tar.gz → 2.2.0__tar.gz

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 (57) hide show
  1. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/PKG-INFO +1 -1
  2. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/__init__.py +1 -1
  3. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/cert_manager.py +109 -18
  4. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/ssl_manager.py +249 -43
  5. mcp_security_framework-2.2.0/mcp_security_framework/examples/cert_inspection_example.py +391 -0
  6. mcp_security_framework-2.2.0/mcp_security_framework/examples/crl_retrieval_example.py +385 -0
  7. mcp_security_framework-2.2.0/mcp_security_framework/examples/protected_keys_example.py +297 -0
  8. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/schemas/config.py +22 -0
  9. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/utils/__init__.py +27 -0
  10. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/utils/cert_utils.py +206 -7
  11. mcp_security_framework-2.2.0/mcp_security_framework/utils/crl_client.py +408 -0
  12. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/PKG-INFO +1 -1
  13. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/SOURCES.txt +4 -0
  14. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/pyproject.toml +1 -1
  15. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/LICENSE +0 -0
  16. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/README.md +0 -0
  17. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/cli/__init__.py +0 -0
  18. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/cli/cert_cli.py +0 -0
  19. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/cli/security_cli.py +0 -0
  20. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/constants.py +0 -0
  21. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/__init__.py +0 -0
  22. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/adapter_wrapper.py +0 -0
  23. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/audit_logger.py +0 -0
  24. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/auth_manager.py +0 -0
  25. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/permission_manager.py +0 -0
  26. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/rate_limiter.py +0 -0
  27. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/security_adapter.py +0 -0
  28. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/core/security_manager.py +0 -0
  29. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/__init__.py +0 -0
  30. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/adapter_example.py +0 -0
  31. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/comprehensive_example.py +0 -0
  32. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/fastapi_example.py +0 -0
  33. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/flask_example.py +0 -0
  34. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/gateway_example.py +0 -0
  35. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/microservice_example.py +0 -0
  36. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/examples/standalone_example.py +0 -0
  37. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/__init__.py +0 -0
  38. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/auth_middleware.py +0 -0
  39. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/fastapi_auth_middleware.py +0 -0
  40. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/fastapi_middleware.py +0 -0
  41. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/flask_auth_middleware.py +0 -0
  42. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/flask_middleware.py +0 -0
  43. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/mtls_middleware.py +0 -0
  44. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/rate_limit_middleware.py +0 -0
  45. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/middleware/security_middleware.py +0 -0
  46. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/schemas/__init__.py +0 -0
  47. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/schemas/models.py +0 -0
  48. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/schemas/operation_context.py +0 -0
  49. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/schemas/responses.py +0 -0
  50. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/utils/crypto_utils.py +0 -0
  51. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/utils/datetime_compat.py +0 -0
  52. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework/utils/validation_utils.py +0 -0
  53. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/dependency_links.txt +0 -0
  54. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/entry_points.txt +0 -0
  55. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/requires.txt +0 -0
  56. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/mcp_security_framework.egg-info/top_level.txt +0 -0
  57. {mcp_security_framework-2.1.0 → mcp_security_framework-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-security-framework
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Universal security framework for microservices with SSL/TLS, authentication, authorization, and rate limiting. Added extensible security adapter system for custom operation validation, structured audit logging, and operation context support. Comprehensive test coverage with 1104+ tests. Fully backward compatible. Requires cryptography>=42.0.0 for certificate operations.
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Maintainer-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
@@ -79,7 +79,7 @@ from mcp_security_framework.schemas.responses import (
79
79
  )
80
80
 
81
81
  # Version information
82
- __version__ = "2.1.0"
82
+ __version__ = "2.2.0"
83
83
  __author__ = "Vasiliy Zdanovskiy"
84
84
  __email__ = "vasilyvz@gmail.com"
85
85
  __license__ = "MIT"
@@ -36,6 +36,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
36
36
  from cryptography import x509
37
37
  from cryptography.hazmat.primitives import hashes, serialization
38
38
  from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
39
+ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
39
40
  from cryptography.x509.oid import ExtendedKeyUsageOID, ExtensionOID, NameOID
40
41
 
41
42
  from mcp_security_framework.schemas.config import (
@@ -53,6 +54,7 @@ from mcp_security_framework.schemas.models import (
53
54
  UnknownRoleError,
54
55
  )
55
56
  from mcp_security_framework.utils.cert_utils import (
57
+ CertificateError,
56
58
  extract_permissions_from_certificate,
57
59
  extract_roles_from_certificate,
58
60
  get_certificate_expiry,
@@ -61,6 +63,7 @@ from mcp_security_framework.utils.cert_utils import (
61
63
  is_certificate_revoked,
62
64
  is_crl_valid,
63
65
  parse_certificate,
66
+ parse_private_key,
64
67
  validate_certificate_against_crl,
65
68
  validate_certificate_chain,
66
69
  )
@@ -123,6 +126,39 @@ def _get_signing_algorithm(private_key):
123
126
  return hashes.SHA256()
124
127
 
125
128
 
129
+ def _load_private_key(
130
+ key_bytes: bytes,
131
+ password: Optional[bytes],
132
+ source: str,
133
+ ) -> PrivateKeyTypes:
134
+ """
135
+ Load a private key from PEM or DER bytes with optional passphrase.
136
+
137
+ This helper wraps parse_private_key to attach a source label and convert
138
+ from CertificateError (raised by parse_private_key) to CertificateValidationError
139
+ (expected by callers in this module). The classification of whether the key
140
+ is encrypted, whether a password was supplied, and whether the password is
141
+ correct all lives in parse_private_key; this wrapper's only job is to add
142
+ the source context and bridge the exception type.
143
+
144
+ Args:
145
+ key_bytes: The PEM or DER-encoded private key
146
+ password: Optional passphrase as bytes, or None
147
+ source: Human-readable label for error messages (e.g. "CA private key",
148
+ "Parent CA private key"). Included in all error messages.
149
+
150
+ Returns:
151
+ Loaded private key object
152
+
153
+ Raises:
154
+ CertificateValidationError: When the key cannot be loaded or decrypted
155
+ """
156
+ try:
157
+ return parse_private_key(key_bytes, password=password)
158
+ except CertificateError as exc:
159
+ raise CertificateValidationError(f"{source}: {exc}", error_code=-32003) from exc
160
+
161
+
126
162
  def _subject_attribute(certificate, oid) -> str:
127
163
  """
128
164
  Return one subject attribute of a certificate, or "" when it carries none.
@@ -616,8 +652,15 @@ class CertificateManager:
616
652
  parent_ca_cert = x509.load_pem_x509_certificate(f.read())
617
653
 
618
654
  with open(intermediate_config.parent_ca_key, "rb") as f:
619
- parent_ca_key = serialization.load_pem_private_key(
620
- f.read(), password=None
655
+ password = (
656
+ intermediate_config.parent_ca_key_password.encode()
657
+ if intermediate_config.parent_ca_key_password
658
+ else None
659
+ )
660
+ parent_ca_key = _load_private_key(
661
+ f.read(),
662
+ password=password,
663
+ source="Parent CA private key",
621
664
  )
622
665
 
623
666
  # Generate intermediate CA private key
@@ -878,7 +921,16 @@ class CertificateManager:
878
921
  ca_cert = x509.load_pem_x509_certificate(f.read())
879
922
 
880
923
  with open(client_config.ca_key_path, "rb") as f:
881
- ca_key = serialization.load_pem_private_key(f.read(), password=None)
924
+ password = (
925
+ client_config.ca_key_password.encode()
926
+ if client_config.ca_key_password
927
+ else None
928
+ )
929
+ ca_key = _load_private_key(
930
+ f.read(),
931
+ password=password,
932
+ source="CA private key",
933
+ )
882
934
 
883
935
  # Verify that CA private key matches CA certificate
884
936
  _verify_ca_key_matches_cert(
@@ -1104,8 +1156,8 @@ class CertificateManager:
1104
1156
  )
1105
1157
  except (FileNotFoundError, PermissionError, ValueError):
1106
1158
  raise
1107
- except CertificateGenerationError:
1108
- # Re-raise CertificateGenerationError without wrapping
1159
+ except (CertificateGenerationError, CertificateValidationError):
1160
+ # Re-raise these without wrapping
1109
1161
  raise
1110
1162
  except Exception as e:
1111
1163
  self.logger.error(
@@ -1189,7 +1241,16 @@ class CertificateManager:
1189
1241
  ca_cert = x509.load_pem_x509_certificate(f.read())
1190
1242
 
1191
1243
  with open(server_config.ca_key_path, "rb") as f:
1192
- ca_key = serialization.load_pem_private_key(f.read(), password=None)
1244
+ password = (
1245
+ server_config.ca_key_password.encode()
1246
+ if server_config.ca_key_password
1247
+ else None
1248
+ )
1249
+ ca_key = _load_private_key(
1250
+ f.read(),
1251
+ password=password,
1252
+ source="CA private key",
1253
+ )
1193
1254
 
1194
1255
  # Verify that CA private key matches CA certificate
1195
1256
  _verify_ca_key_matches_cert(
@@ -1406,8 +1467,8 @@ class CertificateManager:
1406
1467
  )
1407
1468
  except (FileNotFoundError, PermissionError, ValueError):
1408
1469
  raise
1409
- except CertificateGenerationError:
1410
- # Re-raise CertificateGenerationError without wrapping
1470
+ except (CertificateGenerationError, CertificateValidationError):
1471
+ # Re-raise these without wrapping
1411
1472
  raise
1412
1473
  except Exception as e:
1413
1474
  self.logger.error(
@@ -1493,7 +1554,16 @@ class CertificateManager:
1493
1554
  ca_cert = x509.load_pem_x509_certificate(f.read())
1494
1555
 
1495
1556
  with open(ca_key_file, "rb") as f:
1496
- ca_key = serialization.load_pem_private_key(f.read(), password=None)
1557
+ password = (
1558
+ self.config.ca_key_password.encode()
1559
+ if self.config.ca_key_password
1560
+ else None
1561
+ )
1562
+ ca_key = _load_private_key(
1563
+ f.read(),
1564
+ password=password,
1565
+ source="CA private key",
1566
+ )
1497
1567
 
1498
1568
  # Create new certificate with extended validity
1499
1569
  builder = x509.CertificateBuilder()
@@ -1651,7 +1721,16 @@ class CertificateManager:
1651
1721
  ca_cert = x509.load_pem_x509_certificate(f.read())
1652
1722
 
1653
1723
  with open(ca_key_file, "rb") as f:
1654
- ca_key = serialization.load_pem_private_key(f.read(), password=None)
1724
+ password = (
1725
+ self.config.ca_key_password.encode()
1726
+ if self.config.ca_key_password
1727
+ else None
1728
+ )
1729
+ ca_key = _load_private_key(
1730
+ f.read(),
1731
+ password=password,
1732
+ source="CA private key",
1733
+ )
1655
1734
 
1656
1735
  # Create CRL builder
1657
1736
  builder = x509.CertificateRevocationListBuilder()
@@ -2165,8 +2244,15 @@ class CertificateManager:
2165
2244
  # Load CA private key
2166
2245
  with open(ca_key_path, "rb") as f:
2167
2246
  ca_key_data = f.read()
2168
- ca_private_key = serialization.load_pem_private_key(
2169
- ca_key_data, password=None
2247
+ password = (
2248
+ self.config.ca_key_password.encode()
2249
+ if self.config.ca_key_password
2250
+ else None
2251
+ )
2252
+ ca_private_key = _load_private_key(
2253
+ ca_key_data,
2254
+ password=password,
2255
+ source="CA private key",
2170
2256
  )
2171
2257
 
2172
2258
  # Verify that CA private key matches CA certificate
@@ -2249,8 +2335,8 @@ class CertificateManager:
2249
2335
 
2250
2336
  except (FileNotFoundError, PermissionError, ValueError):
2251
2337
  raise
2252
- except CertificateGenerationError:
2253
- # Re-raise CertificateGenerationError without wrapping
2338
+ except (CertificateGenerationError, CertificateValidationError):
2339
+ # Re-raise these without wrapping
2254
2340
  raise
2255
2341
  except Exception as e:
2256
2342
  self.logger.error(
@@ -2495,11 +2581,16 @@ class CertificateManager:
2495
2581
  return key_data.decode("utf-8")
2496
2582
  elif format.lower() == "der":
2497
2583
  # Convert PEM to DER
2498
- from cryptography.hazmat.primitives.serialization import (
2499
- load_pem_private_key,
2584
+ password = (
2585
+ self.config.ca_key_password.encode()
2586
+ if self.config.ca_key_password
2587
+ else None
2588
+ )
2589
+ key = _load_private_key(
2590
+ key_data,
2591
+ password=password,
2592
+ source="Private key",
2500
2593
  )
2501
-
2502
- key = load_pem_private_key(key_data, password=None)
2503
2594
  return key.private_bytes(
2504
2595
  encoding=serialization.Encoding.DER,
2505
2596
  format=serialization.PrivateFormat.PKCS8,
@@ -24,7 +24,9 @@ License: MIT
24
24
  """
25
25
 
26
26
  import logging
27
+ import os
27
28
  import ssl
29
+ import tempfile
28
30
  from pathlib import Path
29
31
  from typing import Dict, List, Optional
30
32
 
@@ -42,6 +44,39 @@ from ..utils.cert_utils import (
42
44
  from .cert_manager import CertificateValidationError, _certificate_type_of
43
45
 
44
46
 
47
+ def _stage_pem_material(data: bytes, label: str) -> str:
48
+ """
49
+ Write PEM bytes to a private temporary file and return its path.
50
+
51
+ The file descriptor from tempfile.mkstemp is taken ownership of by os.fdopen,
52
+ which ensures it is closed exactly once when the stream closes. This avoids
53
+ the double-close bug that occurs when a descriptor is closed manually and then
54
+ closed again in a finally block, which can close unrelated files opened in
55
+ between.
56
+
57
+ Args:
58
+ data: PEM-encoded bytes to write
59
+ label: Prefix for the temporary file (for debugging)
60
+
61
+ Returns:
62
+ Path to the temporary file
63
+
64
+ Raises:
65
+ OSError: If file creation or writing fails
66
+ """
67
+ # Note: the chmod call once done here would have been 0600, but
68
+ # tempfile.mkstemp already creates files with mode 0600, so an
69
+ # explicit chmod was redundant; removed to avoid reader confusion.
70
+ handle, path = tempfile.mkstemp(prefix="mcp-ssl-", suffix=".pem")
71
+ try:
72
+ with os.fdopen(handle, "wb") as stream:
73
+ stream.write(data)
74
+ except BaseException:
75
+ os.unlink(path)
76
+ raise
77
+ return path
78
+
79
+
45
80
  class SSLManager:
46
81
  """
47
82
  SSL/TLS Management Class
@@ -112,6 +147,8 @@ class SSLManager:
112
147
  self,
113
148
  cert_file: Optional[str] = None,
114
149
  key_file: Optional[str] = None,
150
+ cert_data: Optional[bytes] = None,
151
+ key_data: Optional[bytes] = None,
115
152
  ca_cert_file: Optional[str] = None,
116
153
  verify_mode: Optional[str] = None,
117
154
  min_version: Optional[str] = None,
@@ -126,10 +163,20 @@ class SSLManager:
126
163
  Args:
127
164
  cert_file (Optional[str]): Path to server certificate file.
128
165
  If None, uses certificate from config. Must be a valid PEM
129
- or DER certificate file path.
166
+ or DER certificate file path. Incompatible with cert_data.
130
167
  key_file (Optional[str]): Path to server private key file.
131
168
  If None, uses key from config. Must be a valid PEM or DER
132
- private key file path.
169
+ private key file path. Incompatible with key_data.
170
+ cert_data (Optional[bytes]): Server certificate as PEM bytes.
171
+ If provided, used instead of cert_file. Incompatible with cert_file.
172
+ When supplied, bytes are staged to a temporary file (mode 0600)
173
+ for loading, then deleted. This is a stdlib constraint:
174
+ ssl.SSLContext.load_cert_chain requires real files.
175
+ key_data (Optional[bytes]): Server private key as PEM bytes.
176
+ If provided, used instead of key_file. Incompatible with key_file.
177
+ When supplied, bytes are staged to a temporary file (mode 0600)
178
+ for loading, then deleted. This is a stdlib constraint:
179
+ ssl.SSLContext.load_cert_chain requires real files.
133
180
  ca_cert_file (Optional[str]): Path to CA certificate file for
134
181
  client certificate verification. If None, uses CA from config.
135
182
  Must be a valid PEM certificate file path.
@@ -167,26 +214,44 @@ class SSLManager:
167
214
  >>> # Use context for HTTPS server
168
215
  """
169
216
  try:
217
+ # Validate cert_data/key_data compatibility
218
+ if cert_data is not None and key_data is None:
219
+ raise SSLConfigurationError(
220
+ "cert_data supplied without key_data; both must be present"
221
+ )
222
+ if key_data is not None and cert_data is None:
223
+ raise SSLConfigurationError(
224
+ "key_data supplied without cert_data; both must be present"
225
+ )
226
+
170
227
  # Use config values if not provided
171
- cert_file = cert_file or self.config.cert_file
172
- key_file = key_file or self.config.key_file
228
+ if cert_data is None:
229
+ cert_file = cert_file or self.config.cert_file
230
+ if key_data is None:
231
+ key_file = key_file or self.config.key_file
173
232
  ca_cert_file = ca_cert_file or self.config.ca_cert_file
174
233
  verify_mode = verify_mode or self.config.verify_mode
175
234
  min_version = min_version or self.config.min_tls_version
176
235
 
177
- # Validate required files
178
- if not cert_file:
179
- raise SSLConfigurationError("Server certificate file is required")
180
- if not key_file:
181
- raise SSLConfigurationError("Server private key file is required")
236
+ # Validate required files or data
237
+ if cert_data is None and not cert_file:
238
+ raise SSLConfigurationError(
239
+ "Server certificate file or data is required"
240
+ )
241
+ if key_data is None and not key_file:
242
+ raise SSLConfigurationError(
243
+ "Server private key file or data is required"
244
+ )
182
245
 
183
- # Check cache
184
- cache_key = (
185
- f"server_{cert_file}_{key_file}_{ca_cert_file}"
186
- f"_{verify_mode}_{min_version}"
187
- )
188
- if cache_key in self._contexts:
189
- return self._contexts[cache_key]
246
+ # Check cache (only for file-based contexts)
247
+ cache_key = None
248
+ if cert_data is None and key_data is None:
249
+ cache_key = (
250
+ f"server_{cert_file}_{key_file}_{ca_cert_file}"
251
+ f"_{verify_mode}_{min_version}"
252
+ )
253
+ if cache_key in self._contexts:
254
+ return self._contexts[cache_key]
190
255
 
191
256
  # Create SSL context
192
257
  context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
@@ -197,8 +262,43 @@ class SSLManager:
197
262
  # Set minimum TLS version
198
263
  context.minimum_version = self._get_tls_version(min_version)
199
264
 
200
- # Load certificate and key
201
- context.load_cert_chain(cert_file, key_file)
265
+ # Load certificate and key from data or files
266
+ if cert_data is not None and key_data is not None:
267
+ # Stage in-memory data to temporary files for loading
268
+ cert_temp = _stage_pem_material(cert_data, "cert")
269
+ key_temp = _stage_pem_material(key_data, "key")
270
+ try:
271
+ # Load from temporary files with password from config
272
+ password = (
273
+ self.config.key_password.encode()
274
+ if self.config.key_password
275
+ else None
276
+ )
277
+ if password is not None:
278
+ context.load_cert_chain(cert_temp, key_temp, password=password)
279
+ else:
280
+ context.load_cert_chain(cert_temp, key_temp)
281
+ finally:
282
+ # Clean up temporary files
283
+ try:
284
+ os.unlink(cert_temp)
285
+ except OSError:
286
+ pass
287
+ try:
288
+ os.unlink(key_temp)
289
+ except OSError:
290
+ pass
291
+ else:
292
+ # Load from files with password from config
293
+ password = (
294
+ self.config.key_password.encode()
295
+ if self.config.key_password
296
+ else None
297
+ )
298
+ if password is not None:
299
+ context.load_cert_chain(cert_file, key_file, password=password)
300
+ else:
301
+ context.load_cert_chain(cert_file, key_file)
202
302
 
203
303
  # Load CA certificate if provided
204
304
  if ca_cert_file:
@@ -208,14 +308,16 @@ class SSLManager:
208
308
  if self.config.cipher_suite:
209
309
  context.set_ciphers(self.config.cipher_suite)
210
310
 
211
- # Cache context
212
- self._contexts[cache_key] = context
311
+ # Cache context (only for file-based contexts)
312
+ if cache_key is not None:
313
+ self._contexts[cache_key] = context
213
314
 
214
315
  self.logger.info(
215
316
  "Server SSL context created successfully",
216
317
  extra={
217
318
  "cert_file": cert_file,
218
319
  "key_file": key_file,
320
+ "cert_data_provided": cert_data is not None,
219
321
  "verify_mode": verify_mode,
220
322
  "min_version": min_version,
221
323
  },
@@ -226,7 +328,7 @@ class SSLManager:
226
328
  except Exception as e:
227
329
  self.logger.error(
228
330
  "Failed to create server SSL context",
229
- extra={"cert_file": cert_file, "key_file": key_file, "error": str(e)},
331
+ extra={"error": str(e)},
230
332
  )
231
333
  raise SSLConfigurationError(
232
334
  f"Failed to create server SSL context: {str(e)}"
@@ -237,6 +339,8 @@ class SSLManager:
237
339
  ca_cert_file: Optional[str] = None,
238
340
  client_cert_file: Optional[str] = None,
239
341
  client_key_file: Optional[str] = None,
342
+ cert_data: Optional[bytes] = None,
343
+ key_data: Optional[bytes] = None,
240
344
  verify_mode: Optional[str] = None,
241
345
  min_version: Optional[str] = None,
242
346
  ) -> ssl.SSLContext:
@@ -253,10 +357,20 @@ class SSLManager:
253
357
  Must be a valid PEM certificate file path.
254
358
  client_cert_file (Optional[str]): Path to client certificate file
255
359
  for client authentication. If None, uses client cert from config.
256
- Must be a valid PEM certificate file path.
360
+ Must be a valid PEM certificate file path. Incompatible with cert_data.
257
361
  client_key_file (Optional[str]): Path to client private key file.
258
362
  If None, uses client key from config. Must be a valid PEM
259
- private key file path.
363
+ private key file path. Incompatible with key_data.
364
+ cert_data (Optional[bytes]): Client certificate as PEM bytes.
365
+ If provided, used instead of client_cert_file. Incompatible with
366
+ client_cert_file. When supplied, bytes are staged to a temporary
367
+ file (mode 0600) for loading, then deleted. This is a stdlib
368
+ constraint: ssl.SSLContext.load_cert_chain requires real files.
369
+ key_data (Optional[bytes]): Client private key as PEM bytes.
370
+ If provided, used instead of client_key_file. Incompatible with
371
+ client_key_file. When supplied, bytes are staged to a temporary
372
+ file (mode 0600) for loading, then deleted. This is a stdlib
373
+ constraint: ssl.SSLContext.load_cert_chain requires real files.
260
374
  verify_mode (Optional[str]): SSL verification mode. Valid values:
261
375
  - "CERT_NONE": No certificate verification
262
376
  - "CERT_OPTIONAL": Certificate verification optional
@@ -290,20 +404,34 @@ class SSLManager:
290
404
  >>> # Use context for HTTPS client
291
405
  """
292
406
  try:
407
+ # Validate cert_data/key_data compatibility
408
+ if cert_data is not None and key_data is None:
409
+ raise SSLConfigurationError(
410
+ "cert_data supplied without key_data; both must be present"
411
+ )
412
+ if key_data is not None and cert_data is None:
413
+ raise SSLConfigurationError(
414
+ "key_data supplied without cert_data; both must be present"
415
+ )
416
+
293
417
  # Use config values if not provided
418
+ if cert_data is None:
419
+ client_cert_file = client_cert_file or self.config.client_cert_file
420
+ if key_data is None:
421
+ client_key_file = client_key_file or self.config.client_key_file
294
422
  ca_cert_file = ca_cert_file or self.config.ca_cert_file
295
- client_cert_file = client_cert_file or self.config.client_cert_file
296
- client_key_file = client_key_file or self.config.client_key_file
297
423
  verify_mode = verify_mode or self.config.verify_mode
298
424
  min_version = min_version or self.config.min_tls_version
299
425
 
300
- # Check cache
301
- cache_key = (
302
- f"client_{ca_cert_file}_{client_cert_file}_{client_key_file}"
303
- f"_{verify_mode}_{min_version}"
304
- )
305
- if cache_key in self._contexts:
306
- return self._contexts[cache_key]
426
+ # Check cache (only for file-based contexts)
427
+ cache_key = None
428
+ if cert_data is None and key_data is None:
429
+ cache_key = (
430
+ f"client_{ca_cert_file}_{client_cert_file}_{client_key_file}"
431
+ f"_{verify_mode}_{min_version}"
432
+ )
433
+ if cache_key in self._contexts:
434
+ return self._contexts[cache_key]
307
435
 
308
436
  # Determine actual verify mode (explicit parameter takes precedence)
309
437
  actual_verify_mode = (
@@ -324,8 +452,45 @@ class SSLManager:
324
452
 
325
453
  # Load client certificates for mTLS even when verification is
326
454
  # disabled
327
- if client_cert_file and client_key_file:
328
- context.load_cert_chain(client_cert_file, client_key_file)
455
+ if cert_data is not None and key_data is not None:
456
+ # Stage in-memory data to temporary files for loading
457
+ cert_temp = _stage_pem_material(cert_data, "cert")
458
+ key_temp = _stage_pem_material(key_data, "key")
459
+ try:
460
+ # Load from temporary files with password from config
461
+ password = (
462
+ self.config.client_key_password.encode()
463
+ if self.config.client_key_password
464
+ else None
465
+ )
466
+ if password is not None:
467
+ context.load_cert_chain(
468
+ cert_temp, key_temp, password=password
469
+ )
470
+ else:
471
+ context.load_cert_chain(cert_temp, key_temp)
472
+ finally:
473
+ # Clean up temporary files
474
+ try:
475
+ os.unlink(cert_temp)
476
+ except OSError:
477
+ pass
478
+ try:
479
+ os.unlink(key_temp)
480
+ except OSError:
481
+ pass
482
+ elif client_cert_file and client_key_file:
483
+ password = (
484
+ self.config.client_key_password.encode()
485
+ if self.config.client_key_password
486
+ else None
487
+ )
488
+ if password is not None:
489
+ context.load_cert_chain(
490
+ client_cert_file, client_key_file, password=password
491
+ )
492
+ else:
493
+ context.load_cert_chain(client_cert_file, client_key_file)
329
494
  else:
330
495
  # For other modes, use default context with verification
331
496
  context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
@@ -339,21 +504,60 @@ class SSLManager:
339
504
  context.load_verify_locations(ca_cert_file)
340
505
 
341
506
  # Load client certificate and key if provided (for mTLS)
342
- if client_cert_file and client_key_file:
343
- context.load_cert_chain(client_cert_file, client_key_file)
507
+ if cert_data is not None and key_data is not None:
508
+ # Stage in-memory data to temporary files for loading
509
+ cert_temp = _stage_pem_material(cert_data, "cert")
510
+ key_temp = _stage_pem_material(key_data, "key")
511
+ try:
512
+ # Load from temporary files with password from config
513
+ password = (
514
+ self.config.client_key_password.encode()
515
+ if self.config.client_key_password
516
+ else None
517
+ )
518
+ if password is not None:
519
+ context.load_cert_chain(
520
+ cert_temp, key_temp, password=password
521
+ )
522
+ else:
523
+ context.load_cert_chain(cert_temp, key_temp)
524
+ finally:
525
+ # Clean up temporary files
526
+ try:
527
+ os.unlink(cert_temp)
528
+ except OSError:
529
+ pass
530
+ try:
531
+ os.unlink(key_temp)
532
+ except OSError:
533
+ pass
534
+ elif client_cert_file and client_key_file:
535
+ password = (
536
+ self.config.client_key_password.encode()
537
+ if self.config.client_key_password
538
+ else None
539
+ )
540
+ if password is not None:
541
+ context.load_cert_chain(
542
+ client_cert_file, client_key_file, password=password
543
+ )
544
+ else:
545
+ context.load_cert_chain(client_cert_file, client_key_file)
344
546
 
345
547
  # Configure cipher suites
346
548
  if self.config.cipher_suite:
347
549
  context.set_ciphers(self.config.cipher_suite)
348
550
 
349
- # Cache context
350
- self._contexts[cache_key] = context
551
+ # Cache context (only for file-based contexts)
552
+ if cache_key is not None:
553
+ self._contexts[cache_key] = context
351
554
 
352
555
  self.logger.info(
353
556
  "Client SSL context created successfully",
354
557
  extra={
355
558
  "ca_cert_file": ca_cert_file,
356
559
  "client_cert_file": client_cert_file,
560
+ "cert_data_provided": cert_data is not None,
357
561
  "verify_mode": verify_mode,
358
562
  "min_version": min_version,
359
563
  },
@@ -365,8 +569,6 @@ class SSLManager:
365
569
  self.logger.error(
366
570
  "Failed to create client SSL context",
367
571
  extra={
368
- "ca_cert_file": ca_cert_file,
369
- "client_cert_file": client_cert_file,
370
572
  "error": str(e),
371
573
  },
372
574
  )
@@ -709,7 +911,8 @@ class SSLManager:
709
911
  Check certificate expiry information.
710
912
 
711
913
  This method provides detailed information about certificate
712
- expiry including days until expiry and expiry status.
914
+ expiry including days until expiry and expiry status. The window
915
+ for flagging "expires_soon" is controlled by SSLConfig.expiry_warning_days.
713
916
 
714
917
  Args:
715
918
  cert_path (str): Path to certificate file. Must be a valid
@@ -721,7 +924,8 @@ class SSLManager:
721
924
  - not_before: Certificate validity start date
722
925
  - days_until_expiry: Days until certificate expires
723
926
  - is_expired: Whether certificate is expired
724
- - expires_soon: Whether certificate expires within 30 days
927
+ - expires_soon: Whether certificate expires within the configured
928
+ warning window (SSLConfig.expiry_warning_days)
725
929
  - status: Expiry status (valid, expires_soon, expired)
726
930
  - total_seconds_until_expiry: Seconds until expiry
727
931
 
@@ -741,7 +945,9 @@ class SSLManager:
741
945
  ... print(f"Certificate expires in {days} days")
742
946
  """
743
947
  try:
744
- expiry_info = get_certificate_expiry(cert_path)
948
+ expiry_info = get_certificate_expiry(
949
+ cert_path, warn_days=self.config.expiry_warning_days
950
+ )
745
951
 
746
952
  self.logger.info(
747
953
  "Certificate expiry check completed",