mcp-security-framework 0.1.0__py3-none-any.whl → 1.1.1__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 (58) hide show
  1. mcp_security_framework/__init__.py +26 -15
  2. mcp_security_framework/cli/__init__.py +1 -1
  3. mcp_security_framework/cli/cert_cli.py +233 -197
  4. mcp_security_framework/cli/security_cli.py +324 -234
  5. mcp_security_framework/constants.py +21 -27
  6. mcp_security_framework/core/auth_manager.py +49 -20
  7. mcp_security_framework/core/cert_manager.py +398 -104
  8. mcp_security_framework/core/permission_manager.py +13 -9
  9. mcp_security_framework/core/rate_limiter.py +10 -0
  10. mcp_security_framework/core/security_manager.py +286 -229
  11. mcp_security_framework/examples/__init__.py +6 -0
  12. mcp_security_framework/examples/comprehensive_example.py +954 -0
  13. mcp_security_framework/examples/django_example.py +276 -202
  14. mcp_security_framework/examples/fastapi_example.py +897 -393
  15. mcp_security_framework/examples/flask_example.py +311 -200
  16. mcp_security_framework/examples/gateway_example.py +373 -214
  17. mcp_security_framework/examples/microservice_example.py +337 -172
  18. mcp_security_framework/examples/standalone_example.py +719 -478
  19. mcp_security_framework/examples/test_all_examples.py +572 -0
  20. mcp_security_framework/middleware/__init__.py +46 -55
  21. mcp_security_framework/middleware/auth_middleware.py +62 -63
  22. mcp_security_framework/middleware/fastapi_auth_middleware.py +179 -110
  23. mcp_security_framework/middleware/fastapi_middleware.py +156 -148
  24. mcp_security_framework/middleware/flask_auth_middleware.py +267 -107
  25. mcp_security_framework/middleware/flask_middleware.py +183 -157
  26. mcp_security_framework/middleware/mtls_middleware.py +106 -117
  27. mcp_security_framework/middleware/rate_limit_middleware.py +105 -101
  28. mcp_security_framework/middleware/security_middleware.py +109 -124
  29. mcp_security_framework/schemas/config.py +2 -1
  30. mcp_security_framework/schemas/models.py +19 -6
  31. mcp_security_framework/utils/cert_utils.py +14 -8
  32. mcp_security_framework/utils/datetime_compat.py +116 -0
  33. {mcp_security_framework-0.1.0.dist-info → mcp_security_framework-1.1.1.dist-info}/METADATA +2 -1
  34. mcp_security_framework-1.1.1.dist-info/RECORD +84 -0
  35. tests/conftest.py +303 -0
  36. tests/test_cli/test_cert_cli.py +194 -174
  37. tests/test_cli/test_security_cli.py +274 -247
  38. tests/test_core/test_cert_manager.py +33 -19
  39. tests/test_core/test_security_manager.py +2 -2
  40. tests/test_examples/test_comprehensive_example.py +613 -0
  41. tests/test_examples/test_fastapi_example.py +290 -169
  42. tests/test_examples/test_flask_example.py +304 -162
  43. tests/test_examples/test_standalone_example.py +106 -168
  44. tests/test_integration/test_auth_flow.py +214 -198
  45. tests/test_integration/test_certificate_flow.py +181 -150
  46. tests/test_integration/test_fastapi_integration.py +140 -149
  47. tests/test_integration/test_flask_integration.py +144 -141
  48. tests/test_integration/test_standalone_integration.py +331 -300
  49. tests/test_middleware/test_fastapi_auth_middleware.py +745 -0
  50. tests/test_middleware/test_fastapi_middleware.py +147 -132
  51. tests/test_middleware/test_flask_auth_middleware.py +696 -0
  52. tests/test_middleware/test_flask_middleware.py +201 -179
  53. tests/test_middleware/test_security_middleware.py +151 -130
  54. tests/test_utils/test_datetime_compat.py +147 -0
  55. mcp_security_framework-0.1.0.dist-info/RECORD +0 -76
  56. {mcp_security_framework-0.1.0.dist-info → mcp_security_framework-1.1.1.dist-info}/WHEEL +0 -0
  57. {mcp_security_framework-0.1.0.dist-info → mcp_security_framework-1.1.1.dist-info}/entry_points.txt +0 -0
  58. {mcp_security_framework-0.1.0.dist-info → mcp_security_framework-1.1.1.dist-info}/top_level.txt +0 -0
@@ -43,8 +43,8 @@ from mcp_security_framework.schemas.config import (
43
43
  CAConfig,
44
44
  CertificateConfig,
45
45
  ClientCertConfig,
46
- ServerCertConfig,
47
46
  IntermediateCAConfig,
47
+ ServerCertConfig,
48
48
  )
49
49
  from mcp_security_framework.schemas.models import (
50
50
  CertificateInfo,
@@ -60,6 +60,10 @@ from mcp_security_framework.utils.cert_utils import (
60
60
  parse_certificate,
61
61
  validate_certificate_chain,
62
62
  )
63
+ from mcp_security_framework.utils.datetime_compat import (
64
+ get_not_valid_after_utc,
65
+ get_not_valid_before_utc,
66
+ )
63
67
 
64
68
 
65
69
  class CertificateManager:
@@ -313,8 +317,8 @@ class CertificateManager:
313
317
  serial_number=str(certificate.serial_number),
314
318
  common_name=ca_config.common_name,
315
319
  organization=ca_config.organization,
316
- not_before=certificate.not_valid_before,
317
- not_after=certificate.not_valid_after.replace(tzinfo=timezone.utc),
320
+ not_before=get_not_valid_before_utc(certificate),
321
+ not_after=get_not_valid_after_utc(certificate),
318
322
  certificate_type=CertificateType.ROOT_CA,
319
323
  key_size=ca_config.key_size,
320
324
  )
@@ -340,7 +344,9 @@ class CertificateManager:
340
344
  f"Failed to create root CA certificate: {str(e)}"
341
345
  )
342
346
 
343
- def create_intermediate_ca(self, intermediate_config: IntermediateCAConfig) -> CertificatePair:
347
+ def create_intermediate_ca(
348
+ self, intermediate_config: IntermediateCAConfig
349
+ ) -> CertificatePair:
344
350
  """
345
351
  Create intermediate CA certificate signed by parent CA.
346
352
 
@@ -400,32 +406,47 @@ class CertificateManager:
400
406
  try:
401
407
  # Validate intermediate configuration
402
408
  if not intermediate_config.common_name:
403
- raise ValueError("Common name is required for intermediate CA certificate")
409
+ raise ValueError(
410
+ "Common name is required for intermediate CA certificate"
411
+ )
404
412
 
405
- if not intermediate_config.parent_ca_cert or not intermediate_config.parent_ca_key:
413
+ if (
414
+ not intermediate_config.parent_ca_cert
415
+ or not intermediate_config.parent_ca_key
416
+ ):
406
417
  raise ValueError("Parent CA certificate and key paths are required")
407
418
 
408
419
  # Load parent CA certificate and private key
409
420
  if not os.path.exists(intermediate_config.parent_ca_cert):
410
- raise FileNotFoundError(f"Parent CA certificate not found: {intermediate_config.parent_ca_cert}")
421
+ raise FileNotFoundError(
422
+ f"Parent CA certificate not found: {intermediate_config.parent_ca_cert}"
423
+ )
411
424
 
412
425
  if not os.path.exists(intermediate_config.parent_ca_key):
413
- raise FileNotFoundError(f"Parent CA private key not found: {intermediate_config.parent_ca_key}")
426
+ raise FileNotFoundError(
427
+ f"Parent CA private key not found: {intermediate_config.parent_ca_key}"
428
+ )
414
429
 
415
430
  with open(intermediate_config.parent_ca_cert, "rb") as f:
416
431
  parent_ca_cert = x509.load_pem_x509_certificate(f.read())
417
432
 
418
433
  with open(intermediate_config.parent_ca_key, "rb") as f:
419
- parent_ca_key = serialization.load_pem_private_key(f.read(), password=None)
434
+ parent_ca_key = serialization.load_pem_private_key(
435
+ f.read(), password=None
436
+ )
420
437
 
421
438
  # Generate intermediate CA private key
422
439
  private_key = rsa.generate_private_key(
423
- public_exponent=65537, key_size=intermediate_config.key_size, backend=None
440
+ public_exponent=65537,
441
+ key_size=intermediate_config.key_size,
442
+ backend=None,
424
443
  )
425
444
 
426
445
  # Create certificate subject
427
446
  subject_attributes = [
428
- x509.NameAttribute(NameOID.COMMON_NAME, intermediate_config.common_name),
447
+ x509.NameAttribute(
448
+ NameOID.COMMON_NAME, intermediate_config.common_name
449
+ ),
429
450
  x509.NameAttribute(
430
451
  NameOID.ORGANIZATION_NAME, intermediate_config.organization
431
452
  ),
@@ -434,12 +455,16 @@ class CertificateManager:
434
455
 
435
456
  if intermediate_config.state:
436
457
  subject_attributes.append(
437
- x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, intermediate_config.state)
458
+ x509.NameAttribute(
459
+ NameOID.STATE_OR_PROVINCE_NAME, intermediate_config.state
460
+ )
438
461
  )
439
462
 
440
463
  if intermediate_config.locality:
441
464
  subject_attributes.append(
442
- x509.NameAttribute(NameOID.LOCALITY_NAME, intermediate_config.locality)
465
+ x509.NameAttribute(
466
+ NameOID.LOCALITY_NAME, intermediate_config.locality
467
+ )
443
468
  )
444
469
 
445
470
  if intermediate_config.email:
@@ -457,7 +482,8 @@ class CertificateManager:
457
482
  builder = builder.serial_number(x509.random_serial_number())
458
483
  builder = builder.not_valid_before(datetime.now(timezone.utc))
459
484
  builder = builder.not_valid_after(
460
- datetime.now(timezone.utc) + timedelta(days=intermediate_config.validity_years * 365)
485
+ datetime.now(timezone.utc)
486
+ + timedelta(days=intermediate_config.validity_years * 365)
461
487
  )
462
488
 
463
489
  # Add CA extensions
@@ -487,7 +513,9 @@ class CertificateManager:
487
513
 
488
514
  # Add Authority Key Identifier
489
515
  builder = builder.add_extension(
490
- x509.AuthorityKeyIdentifier.from_issuer_public_key(parent_ca_key.public_key()),
516
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(
517
+ parent_ca_key.public_key()
518
+ ),
491
519
  critical=False,
492
520
  )
493
521
 
@@ -527,15 +555,17 @@ class CertificateManager:
527
555
  cert_pair = CertificatePair(
528
556
  certificate_path=cert_path,
529
557
  private_key_path=key_path,
530
- certificate_pem=certificate.public_bytes(serialization.Encoding.PEM).decode(),
558
+ certificate_pem=certificate.public_bytes(
559
+ serialization.Encoding.PEM
560
+ ).decode(),
531
561
  private_key_pem=private_key.private_bytes(
532
562
  encoding=serialization.Encoding.PEM,
533
563
  format=serialization.PrivateFormat.PKCS8,
534
564
  encryption_algorithm=serialization.NoEncryption(),
535
565
  ).decode(),
536
566
  serial_number=str(certificate.serial_number),
537
- not_before=certificate.not_valid_before.replace(tzinfo=timezone.utc),
538
- not_after=certificate.not_valid_after.replace(tzinfo=timezone.utc),
567
+ not_before=get_not_valid_before_utc(certificate),
568
+ not_after=get_not_valid_after_utc(certificate),
539
569
  common_name=intermediate_config.common_name,
540
570
  organization=intermediate_config.organization,
541
571
  certificate_type=CertificateType.INTERMEDIATE_CA,
@@ -558,7 +588,10 @@ class CertificateManager:
558
588
  except Exception as e:
559
589
  self.logger.error(
560
590
  "Failed to create intermediate CA certificate",
561
- extra={"intermediate_config": intermediate_config.model_dump(), "error": str(e)},
591
+ extra={
592
+ "intermediate_config": intermediate_config.model_dump(),
593
+ "error": str(e),
594
+ },
562
595
  )
563
596
  raise CertificateGenerationError(
564
597
  f"Failed to create intermediate CA certificate: {str(e)}"
@@ -775,8 +808,8 @@ class CertificateManager:
775
808
  serial_number=str(certificate.serial_number),
776
809
  common_name=client_config.common_name,
777
810
  organization=client_config.organization,
778
- not_before=certificate.not_valid_before,
779
- not_after=certificate.not_valid_after.replace(tzinfo=timezone.utc),
811
+ not_before=get_not_valid_before_utc(certificate),
812
+ not_after=get_not_valid_after_utc(certificate),
780
813
  certificate_type=CertificateType.CLIENT,
781
814
  key_size=client_config.key_size,
782
815
  )
@@ -1009,8 +1042,8 @@ class CertificateManager:
1009
1042
  serial_number=str(certificate.serial_number),
1010
1043
  common_name=server_config.common_name,
1011
1044
  organization=server_config.organization,
1012
- not_before=certificate.not_valid_before,
1013
- not_after=certificate.not_valid_after.replace(tzinfo=timezone.utc),
1045
+ not_before=get_not_valid_before_utc(certificate),
1046
+ not_after=get_not_valid_after_utc(certificate),
1014
1047
  certificate_type=CertificateType.SERVER,
1015
1048
  key_size=server_config.key_size,
1016
1049
  )
@@ -1038,52 +1071,54 @@ class CertificateManager:
1038
1071
  )
1039
1072
 
1040
1073
  def renew_certificate(
1041
- self,
1042
- cert_path: str,
1074
+ self,
1075
+ cert_path: str,
1043
1076
  ca_cert_path: Optional[str] = None,
1044
1077
  ca_key_path: Optional[str] = None,
1045
- validity_years: int = 1
1078
+ validity_years: int = 1,
1046
1079
  ) -> CertificatePair:
1047
1080
  """
1048
1081
  Renew an existing certificate with new validity period.
1049
-
1082
+
1050
1083
  This method renews a certificate by creating a new certificate
1051
1084
  with the same subject and key but extended validity period.
1052
-
1085
+
1053
1086
  Args:
1054
1087
  cert_path (str): Path to existing certificate to renew
1055
1088
  ca_cert_path (Optional[str]): Path to CA certificate for signing
1056
1089
  ca_key_path (Optional[str]): Path to CA private key for signing
1057
1090
  validity_years (int): New validity period in years
1058
-
1091
+
1059
1092
  Returns:
1060
1093
  CertificatePair: New certificate pair with extended validity
1061
-
1094
+
1062
1095
  Raises:
1063
1096
  CertificateValidationError: When certificate validation fails
1064
1097
  CertificateGenerationError: When renewal fails
1065
1098
  """
1066
1099
  try:
1067
1100
  # Load existing certificate
1068
- with open(cert_path, 'rb') as f:
1101
+ with open(cert_path, "rb") as f:
1069
1102
  cert_data = f.read()
1070
-
1103
+
1071
1104
  cert = x509.load_pem_x509_certificate(cert_data)
1072
-
1105
+
1073
1106
  # Use provided CA paths or default from config
1074
1107
  ca_cert_file = ca_cert_path or self.config.ca_cert_path
1075
1108
  ca_key_file = ca_key_path or self.config.ca_key_path
1076
-
1109
+
1077
1110
  if not ca_cert_file or not ca_key_file:
1078
- raise CertificateConfigurationError("CA certificate and key paths are required")
1079
-
1111
+ raise CertificateConfigurationError(
1112
+ "CA certificate and key paths are required"
1113
+ )
1114
+
1080
1115
  # Load CA certificate and key
1081
- with open(ca_cert_file, 'rb') as f:
1116
+ with open(ca_cert_file, "rb") as f:
1082
1117
  ca_cert = x509.load_pem_x509_certificate(f.read())
1083
-
1084
- with open(ca_key_file, 'rb') as f:
1118
+
1119
+ with open(ca_key_file, "rb") as f:
1085
1120
  ca_key = serialization.load_pem_private_key(f.read(), password=None)
1086
-
1121
+
1087
1122
  # Create new certificate with extended validity
1088
1123
  builder = x509.CertificateBuilder()
1089
1124
  builder = builder.subject_name(cert.subject)
@@ -1094,34 +1129,37 @@ class CertificateManager:
1094
1129
  builder = builder.not_valid_after(
1095
1130
  datetime.now(timezone.utc) + timedelta(days=365 * validity_years)
1096
1131
  )
1097
-
1132
+
1098
1133
  # Copy extensions from original certificate
1099
1134
  for extension in cert.extensions:
1100
1135
  if extension.oid not in [x509.ExtensionOID.AUTHORITY_KEY_IDENTIFIER]:
1101
- builder = builder.add_extension(extension.value, critical=extension.critical)
1102
-
1136
+ builder = builder.add_extension(
1137
+ extension.value, critical=extension.critical
1138
+ )
1139
+
1103
1140
  # Sign the certificate
1104
1141
  new_cert = builder.sign(ca_key, hashes.SHA256())
1105
-
1142
+
1106
1143
  # Generate new file paths
1107
1144
  cert_dir = os.path.dirname(cert_path)
1108
1145
  cert_name = os.path.splitext(os.path.basename(cert_path))[0]
1109
1146
  new_cert_path = os.path.join(cert_dir, f"{cert_name}_renewed.crt")
1110
1147
  new_key_path = os.path.join(cert_dir, f"{cert_name}_renewed.key")
1111
-
1148
+
1112
1149
  # Save new certificate
1113
- with open(new_cert_path, 'wb') as f:
1150
+ with open(new_cert_path, "wb") as f:
1114
1151
  f.write(new_cert.public_bytes(serialization.Encoding.PEM))
1115
-
1152
+
1116
1153
  # For renewal, we typically keep the same private key
1117
1154
  # Copy the original private key if it exists
1118
- key_path = cert_path.replace('.crt', '.key').replace('.pem', '.key')
1155
+ key_path = cert_path.replace(".crt", ".key").replace(".pem", ".key")
1119
1156
  private_key_pem = ""
1120
1157
  if os.path.exists(key_path):
1121
1158
  import shutil
1159
+
1122
1160
  shutil.copy2(key_path, new_key_path)
1123
1161
  # Read the private key content
1124
- with open(key_path, 'r') as f:
1162
+ with open(key_path, "r") as f:
1125
1163
  private_key_pem = f.read()
1126
1164
  else:
1127
1165
  # Create a placeholder key file
@@ -1131,46 +1169,51 @@ gVdaZKJR+ym6h3Za4ryK42qlz8Rb5lQICyFJi+h5xqkk71B2ELzu2nzmoafs9OTJ
1131
1169
  LjL4Cwf+OLlI1eRybbU8eqBk8i+B6ALB2FuGjZJplP99fejLMM0L5XNwxJt3OwCx
1132
1170
  WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1133
1171
  -----END PRIVATE KEY-----"""
1134
- with open(new_key_path, 'w') as f:
1172
+ with open(new_key_path, "w") as f:
1135
1173
  f.write(placeholder_key)
1136
1174
  private_key_pem = placeholder_key
1137
-
1175
+
1138
1176
  # Create certificate pair
1139
1177
  cert_pair = CertificatePair(
1140
1178
  certificate_path=new_cert_path,
1141
1179
  private_key_path=new_key_path,
1142
- certificate_pem=new_cert.public_bytes(serialization.Encoding.PEM).decode(),
1180
+ certificate_pem=new_cert.public_bytes(
1181
+ serialization.Encoding.PEM
1182
+ ).decode(),
1143
1183
  private_key_pem=private_key_pem,
1144
1184
  serial_number=str(new_cert.serial_number),
1145
1185
  common_name="", # Will be extracted from subject
1146
1186
  organization="", # Will be extracted from subject
1147
- not_before=new_cert.not_valid_before,
1148
- not_after=new_cert.not_valid_after.replace(tzinfo=timezone.utc),
1187
+ not_before=get_not_valid_before_utc(new_cert),
1188
+ not_after=get_not_valid_after_utc(new_cert),
1149
1189
  certificate_type=CertificateType.CLIENT, # Default
1150
1190
  key_size=0, # Will be extracted
1151
1191
  )
1152
-
1192
+
1153
1193
  self.logger.info(
1154
1194
  "Certificate renewed successfully",
1155
1195
  extra={
1156
1196
  "original_cert": cert_path,
1157
1197
  "new_cert": new_cert_path,
1158
- "validity_years": validity_years
1159
- }
1198
+ "validity_years": validity_years,
1199
+ },
1160
1200
  )
1161
-
1201
+
1162
1202
  return cert_pair
1163
-
1203
+
1164
1204
  except Exception as e:
1165
1205
  self.logger.error(
1166
1206
  "Failed to renew certificate",
1167
- extra={"cert_path": cert_path, "error": str(e)}
1207
+ extra={"cert_path": cert_path, "error": str(e)},
1168
1208
  )
1169
1209
  raise CertificateGenerationError(f"Failed to renew certificate: {str(e)}")
1170
1210
 
1171
1211
  def revoke_certificate(
1172
- self, serial_number: str, reason: str = "unspecified",
1173
- ca_cert_path: Optional[str] = None, ca_key_path: Optional[str] = None
1212
+ self,
1213
+ serial_number: str,
1214
+ reason: str = "unspecified",
1215
+ ca_cert_path: Optional[str] = None,
1216
+ ca_key_path: Optional[str] = None,
1174
1217
  ) -> bool:
1175
1218
  """
1176
1219
  Revoke certificate by serial number.
@@ -1211,7 +1254,7 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1211
1254
  # Use provided CA paths or default from config
1212
1255
  ca_cert_file = ca_cert_path or self.config.ca_cert_path
1213
1256
  ca_key_file = ca_key_path or self.config.ca_key_path
1214
-
1257
+
1215
1258
  if not ca_cert_file or not ca_key_file:
1216
1259
  raise CertificateConfigurationError(
1217
1260
  "CA certificate and key paths are required"
@@ -1235,7 +1278,7 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1235
1278
  revoked_cert = x509.RevokedCertificateBuilder()
1236
1279
  revoked_cert = revoked_cert.serial_number(int(serial_number))
1237
1280
  revoked_cert = revoked_cert.revocation_date(datetime.now(timezone.utc))
1238
-
1281
+
1239
1282
  # Build the revoked certificate
1240
1283
  revoked_cert_built = revoked_cert.build()
1241
1284
 
@@ -1247,7 +1290,7 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1247
1290
  # Save CRL
1248
1291
  crl_filename = "ca_crl.pem"
1249
1292
  crl_path = os.path.join(self.config.cert_storage_path, crl_filename)
1250
-
1293
+
1251
1294
  # Ensure directory exists
1252
1295
  os.makedirs(os.path.dirname(crl_path), exist_ok=True)
1253
1296
 
@@ -1378,25 +1421,27 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1378
1421
 
1379
1422
  # Create certificate info
1380
1423
  subject_dict = {}
1381
-
1424
+
1382
1425
  # Add Common Name
1383
1426
  if cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME):
1384
1427
  subject_dict["CN"] = str(
1385
1428
  cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
1386
1429
  )
1387
-
1430
+
1388
1431
  # Add Organization
1389
1432
  if cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME):
1390
1433
  subject_dict["O"] = str(
1391
- cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value
1434
+ cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[
1435
+ 0
1436
+ ].value
1392
1437
  )
1393
-
1438
+
1394
1439
  # Add Country
1395
1440
  if cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME):
1396
1441
  subject_dict["C"] = str(
1397
1442
  cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value
1398
1443
  )
1399
-
1444
+
1400
1445
  cert_info = CertificateInfo(
1401
1446
  subject=subject_dict,
1402
1447
  issuer={
@@ -1411,8 +1456,8 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1411
1456
  )
1412
1457
  },
1413
1458
  serial_number=serial_number,
1414
- not_before=cert.not_valid_before,
1415
- not_after=cert.not_valid_after,
1459
+ not_before=get_not_valid_before_utc(cert),
1460
+ not_after=get_not_valid_after_utc(cert),
1416
1461
  certificate_type=CertificateType.CLIENT, # Default to client, could be enhanced
1417
1462
  key_size=expiry_info.get("key_size", 2048), # Default to 2048 bits
1418
1463
  signature_algorithm=cert.signature_algorithm_oid._name,
@@ -1446,18 +1491,205 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1446
1491
  f"Failed to get certificate info: {str(e)}"
1447
1492
  )
1448
1493
 
1449
- def create_crl(self, ca_cert_path: str, ca_key_path: str,
1450
- output_path: Optional[str] = None, validity_days: int = 30) -> str:
1494
+ def create_certificate_signing_request(
1495
+ self,
1496
+ common_name: str,
1497
+ organization: str,
1498
+ country: str,
1499
+ state: Optional[str] = None,
1500
+ locality: Optional[str] = None,
1501
+ organizational_unit: Optional[str] = None,
1502
+ email: Optional[str] = None,
1503
+ key_size: int = 2048,
1504
+ key_type: str = "rsa",
1505
+ output_path: Optional[str] = None,
1506
+ ) -> Tuple[str, str]:
1507
+ """
1508
+ Create a Certificate Signing Request (CSR).
1509
+
1510
+ This method creates a Certificate Signing Request (CSR) that can be
1511
+ submitted to a Certificate Authority (CA) for signing.
1512
+
1513
+ Args:
1514
+ common_name (str): Common name for the certificate (e.g., domain name)
1515
+ organization (str): Organization name
1516
+ country (str): Country code (e.g., "US", "GB")
1517
+ state (Optional[str]): State or province name
1518
+ locality (Optional[str]): Locality or city name
1519
+ organizational_unit (Optional[str]): Organizational unit name
1520
+ email (Optional[str]): Email address
1521
+ key_size (int): Key size in bits. Defaults to 2048
1522
+ key_type (str): Key type ("rsa" or "ec"). Defaults to "rsa"
1523
+ output_path (Optional[str]): Output directory for CSR and key files.
1524
+ If None, uses default path from configuration
1525
+
1526
+ Returns:
1527
+ Tuple[str, str]: Paths to the created CSR file and private key file
1528
+
1529
+ Raises:
1530
+ ValueError: When required parameters are invalid
1531
+ CertificateGenerationError: When CSR creation fails
1532
+ FileNotFoundError: When output directory is not accessible
1533
+ PermissionError: When output directory is not writable
1534
+
1535
+ Example:
1536
+ >>> cert_manager = CertificateManager(config)
1537
+ >>> csr_path, key_path = cert_manager.create_certificate_signing_request(
1538
+ ... common_name="api.example.com",
1539
+ ... organization="Example Corp",
1540
+ ... country="US",
1541
+ ... state="California"
1542
+ ... )
1543
+ >>> print(f"CSR created: {csr_path}")
1544
+ >>> print(f"Private key created: {key_path}")
1545
+ """
1546
+ try:
1547
+ # Validate required parameters
1548
+ if not common_name:
1549
+ raise ValueError("Common name is required")
1550
+ if not organization:
1551
+ raise ValueError("Organization is required")
1552
+ if not country:
1553
+ raise ValueError("Country is required")
1554
+
1555
+ # Generate private key
1556
+ if key_type.lower() == "rsa":
1557
+ private_key = rsa.generate_private_key(
1558
+ public_exponent=65537, key_size=key_size, backend=None
1559
+ )
1560
+ elif key_type.lower() == "ec":
1561
+ private_key = ec.generate_private_key(ec.SECP256R1(), backend=None)
1562
+ else:
1563
+ raise ValueError(f"Unsupported key type: {key_type}")
1564
+
1565
+ # Create CSR subject
1566
+ subject_attributes = [
1567
+ x509.NameAttribute(NameOID.COMMON_NAME, common_name),
1568
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
1569
+ x509.NameAttribute(NameOID.COUNTRY_NAME, country),
1570
+ ]
1571
+
1572
+ # Add optional attributes
1573
+ if state:
1574
+ subject_attributes.append(
1575
+ x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state)
1576
+ )
1577
+ if locality:
1578
+ subject_attributes.append(
1579
+ x509.NameAttribute(NameOID.LOCALITY_NAME, locality)
1580
+ )
1581
+ if organizational_unit:
1582
+ subject_attributes.append(
1583
+ x509.NameAttribute(
1584
+ NameOID.ORGANIZATIONAL_UNIT_NAME, organizational_unit
1585
+ )
1586
+ )
1587
+ if email:
1588
+ subject_attributes.append(
1589
+ x509.NameAttribute(NameOID.EMAIL_ADDRESS, email)
1590
+ )
1591
+
1592
+ subject = x509.Name(subject_attributes)
1593
+
1594
+ # Create CSR builder
1595
+ csr_builder = x509.CertificateSigningRequestBuilder()
1596
+ csr_builder = csr_builder.subject_name(subject)
1597
+ csr_builder = csr_builder.add_extension(
1598
+ x509.BasicConstraints(ca=False, path_length=None), critical=True
1599
+ )
1600
+
1601
+ # Add Subject Alternative Name extension if common name looks like a domain
1602
+ if "." in common_name and not common_name.startswith("*"):
1603
+ san_extension = x509.SubjectAlternativeName([x509.DNSName(common_name)])
1604
+ csr_builder = csr_builder.add_extension(san_extension, critical=False)
1605
+
1606
+ # Build and sign CSR
1607
+ csr = csr_builder.sign(private_key, hashes.SHA256())
1608
+
1609
+ # Determine output paths
1610
+ if output_path is None:
1611
+ output_dir = Path(self.config.cert_storage_path) / "csr"
1612
+ output_dir.mkdir(parents=True, exist_ok=True)
1613
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1614
+ csr_filename = f"{common_name.replace('.', '_')}_{timestamp}.csr"
1615
+ key_filename = f"{common_name.replace('.', '_')}_{timestamp}.key"
1616
+ csr_path = str(output_dir / csr_filename)
1617
+ key_path = str(output_dir / key_filename)
1618
+ else:
1619
+ output_dir = Path(output_path)
1620
+ output_dir.mkdir(parents=True, exist_ok=True)
1621
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1622
+ csr_filename = f"{common_name.replace('.', '_')}_{timestamp}.csr"
1623
+ key_filename = f"{common_name.replace('.', '_')}_{timestamp}.key"
1624
+ csr_path = str(output_dir / csr_filename)
1625
+ key_path = str(output_dir / key_filename)
1626
+
1627
+ # Write CSR to file
1628
+ with open(csr_path, "wb") as f:
1629
+ f.write(csr.public_bytes(serialization.Encoding.PEM))
1630
+
1631
+ # Write private key to file
1632
+ with open(key_path, "wb") as f:
1633
+ f.write(
1634
+ private_key.private_bytes(
1635
+ encoding=serialization.Encoding.PEM,
1636
+ format=serialization.PrivateFormat.PKCS8,
1637
+ encryption_algorithm=serialization.NoEncryption(),
1638
+ )
1639
+ )
1640
+
1641
+ # Set proper permissions
1642
+ os.chmod(key_path, 0o600) # Read/write for owner only
1643
+ os.chmod(csr_path, 0o644) # Read for all, write for owner
1644
+
1645
+ self.logger.info(
1646
+ "CSR created successfully",
1647
+ extra={
1648
+ "csr_path": csr_path,
1649
+ "key_path": key_path,
1650
+ "common_name": common_name,
1651
+ "organization": organization,
1652
+ "key_type": key_type,
1653
+ "key_size": key_size,
1654
+ },
1655
+ )
1656
+
1657
+ return csr_path, key_path
1658
+
1659
+ except Exception as e:
1660
+ self.logger.error(
1661
+ "Failed to create CSR",
1662
+ extra={
1663
+ "common_name": common_name,
1664
+ "organization": organization,
1665
+ "error": str(e),
1666
+ },
1667
+ )
1668
+ raise CertificateGenerationError(f"Failed to create CSR: {str(e)}")
1669
+
1670
+ def create_crl(
1671
+ self,
1672
+ ca_cert_path: str,
1673
+ ca_key_path: str,
1674
+ revoked_serials: Optional[List[Dict[str, Union[str, int]]]] = None,
1675
+ output_path: Optional[str] = None,
1676
+ validity_days: int = 30,
1677
+ ) -> str:
1451
1678
  """
1452
1679
  Create a Certificate Revocation List (CRL).
1453
1680
 
1454
1681
  This method creates a Certificate Revocation List (CRL) from the CA
1455
1682
  certificate and private key. The CRL contains information about
1456
- revoked certificates.
1683
+ revoked certificates with revocation reasons.
1457
1684
 
1458
1685
  Args:
1459
1686
  ca_cert_path (str): Path to CA certificate file
1460
1687
  ca_key_path (str): Path to CA private key file
1688
+ revoked_serials (Optional[List[Dict]]): List of revoked certificate serials.
1689
+ Each dict should contain:
1690
+ - "serial": Serial number as string or int
1691
+ - "reason": Revocation reason (optional)
1692
+ - "revocation_date": Revocation date (optional, defaults to now)
1461
1693
  output_path (Optional[str]): Output path for CRL file.
1462
1694
  If None, uses default path from configuration
1463
1695
  validity_days (int): CRL validity period in days. Defaults to 30
@@ -1471,9 +1703,14 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1471
1703
 
1472
1704
  Example:
1473
1705
  >>> cert_manager = CertificateManager(config)
1706
+ >>> revoked_serials = [
1707
+ ... {"serial": "123456789", "reason": "key_compromise"},
1708
+ ... {"serial": "987654321", "reason": "certificate_hold"}
1709
+ ... ]
1474
1710
  >>> crl_path = cert_manager.create_crl(
1475
1711
  ... ca_cert_path="/path/to/ca.crt",
1476
1712
  ... ca_key_path="/path/to/ca.key",
1713
+ ... revoked_serials=revoked_serials,
1477
1714
  ... validity_days=30
1478
1715
  ... )
1479
1716
  >>> print(f"CRL created: {crl_path}")
@@ -1482,17 +1719,17 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1482
1719
  # Validate input files
1483
1720
  if not os.path.exists(ca_cert_path):
1484
1721
  raise FileNotFoundError(f"CA certificate not found: {ca_cert_path}")
1485
-
1722
+
1486
1723
  if not os.path.exists(ca_key_path):
1487
1724
  raise FileNotFoundError(f"CA private key not found: {ca_key_path}")
1488
1725
 
1489
1726
  # Load CA certificate
1490
- with open(ca_cert_path, 'rb') as f:
1727
+ with open(ca_cert_path, "rb") as f:
1491
1728
  ca_cert_data = f.read()
1492
1729
  ca_cert = x509.load_pem_x509_certificate(ca_cert_data)
1493
1730
 
1494
1731
  # Load CA private key
1495
- with open(ca_key_path, 'rb') as f:
1732
+ with open(ca_key_path, "rb") as f:
1496
1733
  ca_key_data = f.read()
1497
1734
  ca_private_key = serialization.load_pem_private_key(
1498
1735
  ca_key_data, password=None
@@ -1508,10 +1745,33 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1508
1745
  crl_builder = crl_builder.next_update(next_update)
1509
1746
  crl_builder = crl_builder.issuer_name(ca_cert.subject)
1510
1747
 
1511
- # Add revoked certificates (empty for now, can be enhanced)
1512
- # This is a basic implementation - in a real scenario, you would
1513
- # load revoked certificates from a database or file
1748
+ # Add revoked certificates
1514
1749
  revoked_certificates = []
1750
+ if revoked_serials:
1751
+ for revoked_info in revoked_serials:
1752
+ serial = revoked_info.get("serial")
1753
+ reason = revoked_info.get("reason", "unspecified")
1754
+ revocation_date = revoked_info.get("revocation_date", now)
1755
+
1756
+ # Convert serial to int if it's a string
1757
+ if isinstance(serial, str):
1758
+ serial = (
1759
+ int(serial, 16) if serial.startswith("0x") else int(serial)
1760
+ )
1761
+
1762
+ # Map reason string to x509 enum
1763
+ reason_enum = self._get_revocation_reason(reason)
1764
+
1765
+ # Create revoked certificate entry
1766
+ revoked_cert = (
1767
+ x509.RevokedCertificateBuilder()
1768
+ .serial_number(serial)
1769
+ .revocation_date(revocation_date)
1770
+ .add_extension(x509.CRLReason(reason_enum), critical=False)
1771
+ .build()
1772
+ )
1773
+
1774
+ revoked_certificates.append(revoked_cert)
1515
1775
 
1516
1776
  # Build CRL
1517
1777
  crl = crl_builder.sign(ca_private_key, hashes.SHA256())
@@ -1520,13 +1780,15 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1520
1780
  if output_path is None:
1521
1781
  output_dir = Path(self.config.cert_storage_path) / "crl"
1522
1782
  output_dir.mkdir(parents=True, exist_ok=True)
1523
- output_path = str(output_dir / f"crl_{now.strftime('%Y%m%d_%H%M%S')}.pem")
1783
+ output_path = str(
1784
+ output_dir / f"crl_{now.strftime('%Y%m%d_%H%M%S')}.pem"
1785
+ )
1524
1786
  else:
1525
1787
  output_dir = Path(output_path).parent
1526
1788
  output_dir.mkdir(parents=True, exist_ok=True)
1527
1789
 
1528
1790
  # Write CRL to file
1529
- with open(output_path, 'wb') as f:
1791
+ with open(output_path, "wb") as f:
1530
1792
  f.write(crl.public_bytes(serialization.Encoding.PEM))
1531
1793
 
1532
1794
  self.logger.info(
@@ -1534,8 +1796,8 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1534
1796
  extra={
1535
1797
  "crl_path": output_path,
1536
1798
  "validity_days": validity_days,
1537
- "revoked_count": len(revoked_certificates)
1538
- }
1799
+ "revoked_count": len(revoked_certificates),
1800
+ },
1539
1801
  )
1540
1802
 
1541
1803
  return output_path
@@ -1546,69 +1808,101 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1546
1808
  extra={
1547
1809
  "ca_cert_path": ca_cert_path,
1548
1810
  "ca_key_path": ca_key_path,
1549
- "error": str(e)
1550
- }
1811
+ "error": str(e),
1812
+ },
1551
1813
  )
1552
1814
  raise CertificateGenerationError(f"Failed to create CRL: {str(e)}")
1553
1815
 
1554
- def export_certificate(self, cert_path: str, format: str = "pem") -> Union[str, bytes]:
1816
+ def _get_revocation_reason(self, reason: str) -> x509.ReasonFlags:
1817
+ """
1818
+ Map reason string to x509.ReasonFlags enum.
1819
+
1820
+ Args:
1821
+ reason (str): Reason string
1822
+
1823
+ Returns:
1824
+ x509.ReasonFlags: Corresponding reason enum
1825
+ """
1826
+ reason_map = {
1827
+ "unspecified": x509.ReasonFlags.unspecified,
1828
+ "key_compromise": x509.ReasonFlags.key_compromise,
1829
+ "ca_compromise": x509.ReasonFlags.ca_compromise,
1830
+ "affiliation_changed": x509.ReasonFlags.affiliation_changed,
1831
+ "superseded": x509.ReasonFlags.superseded,
1832
+ "cessation_of_operation": x509.ReasonFlags.cessation_of_operation,
1833
+ "certificate_hold": x509.ReasonFlags.certificate_hold,
1834
+ "privilege_withdrawn": x509.ReasonFlags.privilege_withdrawn,
1835
+ "aa_compromise": x509.ReasonFlags.aa_compromise,
1836
+ }
1837
+
1838
+ return reason_map.get(reason.lower(), x509.ReasonFlags.unspecified)
1839
+
1840
+ def export_certificate(
1841
+ self, cert_path: str, format: str = "pem"
1842
+ ) -> Union[str, bytes]:
1555
1843
  """
1556
1844
  Export certificate to different formats.
1557
-
1845
+
1558
1846
  Args:
1559
1847
  cert_path: Path to certificate file
1560
1848
  format: Export format ("pem" or "der")
1561
-
1849
+
1562
1850
  Returns:
1563
1851
  Certificate content in specified format
1564
1852
  """
1565
1853
  try:
1566
- with open(cert_path, 'rb') as f:
1854
+ with open(cert_path, "rb") as f:
1567
1855
  cert_data = f.read()
1568
-
1856
+
1569
1857
  if format.lower() == "pem":
1570
- return cert_data.decode('utf-8')
1858
+ return cert_data.decode("utf-8")
1571
1859
  elif format.lower() == "der":
1572
1860
  # Convert PEM to DER
1573
1861
  from cryptography import x509
1862
+
1574
1863
  cert = x509.load_pem_x509_certificate(cert_data)
1575
1864
  return cert.public_bytes(serialization.Encoding.DER)
1576
1865
  else:
1577
1866
  raise ValueError(f"Unsupported format: {format}")
1578
-
1867
+
1579
1868
  except Exception as e:
1580
1869
  self.logger.error(f"Failed to export certificate: {str(e)}")
1581
1870
  raise CertificateGenerationError(f"Failed to export certificate: {str(e)}")
1582
1871
 
1583
- def export_private_key(self, key_path: str, format: str = "pem") -> Union[str, bytes]:
1872
+ def export_private_key(
1873
+ self, key_path: str, format: str = "pem"
1874
+ ) -> Union[str, bytes]:
1584
1875
  """
1585
1876
  Export private key to different formats.
1586
-
1877
+
1587
1878
  Args:
1588
1879
  key_path: Path to private key file
1589
1880
  format: Export format ("pem" or "der")
1590
-
1881
+
1591
1882
  Returns:
1592
1883
  Private key content in specified format
1593
1884
  """
1594
1885
  try:
1595
- with open(key_path, 'rb') as f:
1886
+ with open(key_path, "rb") as f:
1596
1887
  key_data = f.read()
1597
-
1888
+
1598
1889
  if format.lower() == "pem":
1599
- return key_data.decode('utf-8')
1890
+ return key_data.decode("utf-8")
1600
1891
  elif format.lower() == "der":
1601
1892
  # Convert PEM to DER
1602
- from cryptography.hazmat.primitives.serialization import load_pem_private_key
1893
+ from cryptography.hazmat.primitives.serialization import (
1894
+ load_pem_private_key,
1895
+ )
1896
+
1603
1897
  key = load_pem_private_key(key_data, password=None)
1604
1898
  return key.private_bytes(
1605
1899
  encoding=serialization.Encoding.DER,
1606
1900
  format=serialization.PrivateFormat.PKCS8,
1607
- encryption_algorithm=serialization.NoEncryption()
1901
+ encryption_algorithm=serialization.NoEncryption(),
1608
1902
  )
1609
1903
  else:
1610
1904
  raise ValueError(f"Unsupported format: {format}")
1611
-
1905
+
1612
1906
  except Exception as e:
1613
1907
  self.logger.error(f"Failed to export private key: {str(e)}")
1614
1908
  raise CertificateGenerationError(f"Failed to export private key: {str(e)}")
@@ -1618,7 +1912,7 @@ WvWwM6xqxW0Sf6s5AxJmTn3amZ0G+aP4Y2AEojlbQR7g5aigKbFQqGDFW07egp6
1618
1912
  # Skip validation if certificate management is disabled
1619
1913
  if not self.config.enabled:
1620
1914
  return
1621
-
1915
+
1622
1916
  if not self.config.ca_cert_path:
1623
1917
  raise CertificateConfigurationError("CA certificate path is required")
1624
1918