provide-foundation 0.0.0.dev0__py3-none-any.whl → 0.0.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. provide/foundation/__init__.py +12 -20
  2. provide/foundation/archive/__init__.py +23 -0
  3. provide/foundation/archive/base.py +70 -0
  4. provide/foundation/archive/bzip2.py +157 -0
  5. provide/foundation/archive/gzip.py +159 -0
  6. provide/foundation/archive/operations.py +336 -0
  7. provide/foundation/archive/tar.py +164 -0
  8. provide/foundation/archive/zip.py +203 -0
  9. provide/foundation/config/base.py +2 -2
  10. provide/foundation/config/sync.py +19 -4
  11. provide/foundation/core.py +1 -2
  12. provide/foundation/crypto/__init__.py +2 -0
  13. provide/foundation/crypto/certificates/__init__.py +34 -0
  14. provide/foundation/crypto/certificates/base.py +173 -0
  15. provide/foundation/crypto/certificates/certificate.py +290 -0
  16. provide/foundation/crypto/certificates/factory.py +213 -0
  17. provide/foundation/crypto/certificates/generator.py +138 -0
  18. provide/foundation/crypto/certificates/loader.py +130 -0
  19. provide/foundation/crypto/certificates/operations.py +198 -0
  20. provide/foundation/crypto/certificates/trust.py +107 -0
  21. provide/foundation/eventsets/__init__.py +0 -0
  22. provide/foundation/eventsets/display.py +84 -0
  23. provide/foundation/eventsets/registry.py +160 -0
  24. provide/foundation/eventsets/resolver.py +192 -0
  25. provide/foundation/eventsets/sets/das.py +128 -0
  26. provide/foundation/eventsets/sets/database.py +125 -0
  27. provide/foundation/eventsets/sets/http.py +153 -0
  28. provide/foundation/eventsets/sets/llm.py +139 -0
  29. provide/foundation/eventsets/sets/task_queue.py +107 -0
  30. provide/foundation/eventsets/types.py +70 -0
  31. provide/foundation/hub/components.py +7 -133
  32. provide/foundation/logger/__init__.py +3 -10
  33. provide/foundation/logger/config/logging.py +6 -6
  34. provide/foundation/logger/core.py +0 -2
  35. provide/foundation/logger/custom_processors.py +1 -0
  36. provide/foundation/logger/factories.py +11 -2
  37. provide/foundation/logger/processors/main.py +20 -84
  38. provide/foundation/logger/setup/__init__.py +5 -1
  39. provide/foundation/logger/setup/coordinator.py +75 -23
  40. provide/foundation/logger/setup/processors.py +2 -9
  41. provide/foundation/logger/trace.py +27 -0
  42. provide/foundation/metrics/otel.py +10 -10
  43. provide/foundation/process/lifecycle.py +82 -26
  44. provide/foundation/testing/__init__.py +77 -0
  45. provide/foundation/testing/archive/__init__.py +24 -0
  46. provide/foundation/testing/archive/fixtures.py +217 -0
  47. provide/foundation/testing/common/__init__.py +34 -0
  48. provide/foundation/testing/common/fixtures.py +263 -0
  49. provide/foundation/testing/file/__init__.py +40 -0
  50. provide/foundation/testing/file/fixtures.py +523 -0
  51. provide/foundation/testing/logger.py +41 -11
  52. provide/foundation/testing/mocking/__init__.py +46 -0
  53. provide/foundation/testing/mocking/fixtures.py +331 -0
  54. provide/foundation/testing/process/__init__.py +48 -0
  55. provide/foundation/testing/process/fixtures.py +577 -0
  56. provide/foundation/testing/threading/__init__.py +38 -0
  57. provide/foundation/testing/threading/fixtures.py +520 -0
  58. provide/foundation/testing/time/__init__.py +32 -0
  59. provide/foundation/testing/time/fixtures.py +409 -0
  60. provide/foundation/testing/transport/__init__.py +30 -0
  61. provide/foundation/testing/transport/fixtures.py +280 -0
  62. provide/foundation/tools/__init__.py +58 -0
  63. provide/foundation/tools/base.py +348 -0
  64. provide/foundation/tools/cache.py +266 -0
  65. provide/foundation/tools/downloader.py +213 -0
  66. provide/foundation/tools/installer.py +254 -0
  67. provide/foundation/tools/registry.py +223 -0
  68. provide/foundation/tools/resolver.py +321 -0
  69. provide/foundation/tools/verifier.py +186 -0
  70. provide/foundation/tracer/otel.py +7 -11
  71. provide/foundation/transport/__init__.py +155 -0
  72. provide/foundation/transport/base.py +171 -0
  73. provide/foundation/transport/client.py +266 -0
  74. provide/foundation/transport/config.py +209 -0
  75. provide/foundation/transport/errors.py +79 -0
  76. provide/foundation/transport/http.py +232 -0
  77. provide/foundation/transport/middleware.py +366 -0
  78. provide/foundation/transport/registry.py +167 -0
  79. provide/foundation/transport/types.py +45 -0
  80. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/METADATA +5 -28
  81. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/RECORD +85 -34
  82. provide/foundation/cli/commands/logs/generate_old.py +0 -569
  83. provide/foundation/crypto/certificates.py +0 -896
  84. provide/foundation/logger/emoji/__init__.py +0 -44
  85. provide/foundation/logger/emoji/matrix.py +0 -209
  86. provide/foundation/logger/emoji/sets.py +0 -458
  87. provide/foundation/logger/emoji/types.py +0 -56
  88. provide/foundation/logger/setup/emoji_resolver.py +0 -64
  89. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/WHEEL +0 -0
  90. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/entry_points.txt +0 -0
  91. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/licenses/LICENSE +0 -0
  92. {provide_foundation-0.0.0.dev0.dist-info → provide_foundation-0.0.0.dev1.dist-info}/top_level.txt +0 -0
@@ -1,896 +0,0 @@
1
- """X.509 certificate generation and management."""
2
-
3
- from datetime import UTC, datetime, timedelta
4
- from enum import StrEnum, auto
5
- from functools import cached_property
6
- import os
7
- from pathlib import Path
8
- import traceback
9
- from typing import NotRequired, Self, TypeAlias, TypedDict, cast
10
-
11
- from attrs import Factory, define, field
12
-
13
- try:
14
- from cryptography import x509
15
- from cryptography.hazmat.backends import default_backend
16
- from cryptography.hazmat.primitives import hashes, serialization
17
- from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
18
- from cryptography.hazmat.primitives.serialization import load_pem_private_key
19
- from cryptography.x509 import Certificate as X509Certificate
20
- from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
21
-
22
- _HAS_CRYPTO = True
23
- except ImportError:
24
- # Stub out cryptography types for type hints
25
- x509 = None
26
- default_backend = None
27
- hashes = None
28
- serialization = None
29
- ec = None
30
- padding = None
31
- rsa = None
32
- load_pem_private_key = None
33
- X509Certificate = None
34
- ExtendedKeyUsageOID = None
35
- NameOID = None
36
- _HAS_CRYPTO = False
37
-
38
- from provide.foundation import logger
39
- from provide.foundation.crypto.constants import (
40
- DEFAULT_CERTIFICATE_CURVE,
41
- DEFAULT_CERTIFICATE_KEY_TYPE,
42
- DEFAULT_CERTIFICATE_VALIDITY_DAYS,
43
- DEFAULT_RSA_KEY_SIZE,
44
- )
45
- from provide.foundation.errors.config import ValidationError
46
-
47
-
48
- def _require_crypto():
49
- """Ensure cryptography is available for crypto operations."""
50
- if not _HAS_CRYPTO:
51
- raise ImportError(
52
- "Cryptography features require optional dependencies. Install with: "
53
- "pip install 'provide-foundation[crypto]'"
54
- )
55
-
56
-
57
- class CertificateError(ValidationError):
58
- """Certificate-related errors."""
59
-
60
- def __init__(self, message: str, hint: str | None = None) -> None:
61
- super().__init__(
62
- message=message,
63
- field="certificate",
64
- value=None,
65
- rule=hint or "Certificate operation failed",
66
- )
67
-
68
-
69
- class KeyType(StrEnum):
70
- RSA = auto()
71
- ECDSA = auto()
72
-
73
-
74
- class CurveType(StrEnum):
75
- SECP256R1 = auto()
76
- SECP384R1 = auto()
77
- SECP521R1 = auto()
78
-
79
-
80
- class CertificateConfig(TypedDict):
81
- common_name: str
82
- organization: str
83
- alt_names: list[str]
84
- key_type: KeyType
85
- not_valid_before: datetime
86
- not_valid_after: datetime
87
- # Optional key generation parameters
88
- key_size: NotRequired[int]
89
- curve: NotRequired[CurveType]
90
-
91
-
92
- if _HAS_CRYPTO:
93
- KeyPair: TypeAlias = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey
94
- PublicKey: TypeAlias = rsa.RSAPublicKey | ec.EllipticCurvePublicKey
95
- else:
96
- KeyPair: TypeAlias = None
97
- PublicKey: TypeAlias = None
98
-
99
-
100
- @define(slots=True, frozen=True)
101
- class CertificateBase:
102
- """Immutable base certificate data."""
103
-
104
- subject: "x509.Name"
105
- issuer: "x509.Name"
106
- public_key: "PublicKey"
107
- not_valid_before: datetime
108
- not_valid_after: datetime
109
- serial_number: int
110
-
111
- @classmethod
112
- def create(cls, config: CertificateConfig) -> tuple[Self, "KeyPair"]:
113
- """Create a new certificate base and private key."""
114
- _require_crypto()
115
- try:
116
- logger.debug("📜📝🚀 CertificateBase.create: Starting base creation")
117
- not_valid_before = config["not_valid_before"]
118
- not_valid_after = config["not_valid_after"]
119
-
120
- if not_valid_before.tzinfo is None:
121
- not_valid_before = not_valid_before.replace(tzinfo=UTC)
122
- if not_valid_after.tzinfo is None:
123
- not_valid_after = not_valid_after.replace(tzinfo=UTC)
124
-
125
- logger.debug(
126
- f"📜⏳✅ CertificateBase.create: Using validity: "
127
- f"{not_valid_before} to {not_valid_after}"
128
- )
129
-
130
- private_key: KeyPair
131
- match config["key_type"]:
132
- case KeyType.RSA:
133
- key_size = config.get("key_size", DEFAULT_RSA_KEY_SIZE)
134
- logger.debug(f"📜🔑🚀 Generating RSA key (size: {key_size})")
135
- private_key = rsa.generate_private_key(
136
- public_exponent=65537, key_size=key_size
137
- )
138
- case KeyType.ECDSA:
139
- curve_choice = config.get("curve", CurveType.SECP384R1)
140
- logger.debug(f"📜🔑🚀 Generating ECDSA key (curve: {curve_choice})")
141
- curve = getattr(ec, curve_choice.name)()
142
- private_key = ec.generate_private_key(curve)
143
- case _:
144
- raise ValueError(
145
- f"Internal Error: Unsupported key type: {config['key_type']}"
146
- )
147
-
148
- subject = cls._create_name(config["common_name"], config["organization"])
149
- issuer = cls._create_name(config["common_name"], config["organization"])
150
-
151
- serial_number = x509.random_serial_number()
152
- logger.debug(f"📜🔑✅ Generated serial number: {serial_number}")
153
-
154
- base = cls(
155
- subject=subject,
156
- issuer=issuer,
157
- public_key=private_key.public_key(),
158
- not_valid_before=not_valid_before,
159
- not_valid_after=not_valid_after,
160
- serial_number=serial_number,
161
- )
162
- logger.debug("📜📝✅ CertificateBase.create: Base creation complete")
163
- return base, private_key
164
-
165
- except Exception as e:
166
- logger.error(
167
- f"📜❌ CertificateBase.create: Failed: {e}",
168
- extra={"error": str(e), "trace": traceback.format_exc()},
169
- )
170
- raise CertificateError(f"Failed to generate certificate base: {e}") from e
171
-
172
- @staticmethod
173
- def _create_name(common_name: str, org: str) -> "x509.Name":
174
- """Helper method to construct an X.509 name."""
175
- return x509.Name(
176
- [
177
- x509.NameAttribute(NameOID.COMMON_NAME, common_name),
178
- x509.NameAttribute(NameOID.ORGANIZATION_NAME, org),
179
- ]
180
- )
181
-
182
-
183
- @define(slots=True, eq=False, hash=False, repr=False)
184
- class Certificate:
185
- """X.509 certificate management using attrs."""
186
-
187
- cert_pem_or_uri: str | None = field(default=None, kw_only=True)
188
- key_pem_or_uri: str | None = field(default=None, kw_only=True)
189
- generate_keypair: bool = field(default=False, kw_only=True)
190
- key_type: str = field(default=DEFAULT_CERTIFICATE_KEY_TYPE, kw_only=True)
191
- key_size: int = field(default=DEFAULT_RSA_KEY_SIZE, kw_only=True)
192
- ecdsa_curve: str = field(default=DEFAULT_CERTIFICATE_CURVE, kw_only=True)
193
- common_name: str = field(default="localhost", kw_only=True)
194
- alt_names: list[str] | None = field(
195
- default=Factory(lambda: ["localhost"]), kw_only=True
196
- )
197
- organization_name: str = field(default="Default Organization", kw_only=True)
198
- validity_days: int = field(default=DEFAULT_CERTIFICATE_VALIDITY_DAYS, kw_only=True)
199
-
200
- _base: CertificateBase = field(init=False, repr=False)
201
- _private_key: "KeyPair | None" = field(init=False, default=None, repr=False)
202
- _cert: "X509Certificate" = field(init=False, repr=False)
203
- _trust_chain: list["Certificate"] = field(init=False, factory=list, repr=False)
204
-
205
- cert: str = field(init=False, default="", repr=True)
206
- key: str | None = field(init=False, default=None, repr=False)
207
-
208
- def __attrs_post_init__(self) -> None:
209
- """Handle loading or generation logic after attrs initialization."""
210
- try:
211
- if self.generate_keypair:
212
- logger.debug(
213
- "📜🔑🚀 Certificate.__attrs_post_init__: Generating new keypair"
214
- )
215
-
216
- now = datetime.now(UTC)
217
- not_valid_before = now - timedelta(days=1)
218
- not_valid_after = now + timedelta(days=self.validity_days)
219
-
220
- normalized_key_type_str = self.key_type.lower()
221
- match normalized_key_type_str:
222
- case "rsa":
223
- gen_key_type = KeyType.RSA
224
- case "ecdsa":
225
- gen_key_type = KeyType.ECDSA
226
- case _:
227
- raise ValueError(
228
- f"Unsupported key_type string: '{self.key_type}'. "
229
- "Must be 'rsa' or 'ecdsa'."
230
- )
231
-
232
- gen_curve: CurveType | None = None
233
- gen_key_size = None
234
-
235
- if gen_key_type == KeyType.ECDSA:
236
- try:
237
- gen_curve = CurveType[self.ecdsa_curve.upper()]
238
- except KeyError as e_curve:
239
- raise ValueError(
240
- f"Unsupported ECDSA curve: {self.ecdsa_curve}"
241
- ) from e_curve
242
- else: # RSA
243
- gen_key_size = self.key_size
244
-
245
- conf: CertificateConfig = {
246
- "common_name": self.common_name,
247
- "organization": self.organization_name,
248
- "alt_names": self.alt_names or ["localhost"],
249
- "key_type": gen_key_type,
250
- "not_valid_before": not_valid_before,
251
- "not_valid_after": not_valid_after,
252
- }
253
- if gen_curve is not None:
254
- conf["curve"] = gen_curve
255
- if gen_key_size is not None:
256
- conf["key_size"] = gen_key_size
257
- logger.debug(f"📜🔑🚀 Generation config: {conf}")
258
-
259
- self._base, self._private_key = CertificateBase.create(conf)
260
-
261
- self._cert = self._create_x509_certificate(
262
- is_ca=False,
263
- is_client_cert=True,
264
- )
265
-
266
- if self._cert is None:
267
- raise CertificateError(
268
- "Certificate object (_cert) is None after creation"
269
- )
270
-
271
- self.cert = self._cert.public_bytes(serialization.Encoding.PEM).decode(
272
- "utf-8"
273
- )
274
- if self._private_key:
275
- self.key = self._private_key.private_bytes(
276
- encoding=serialization.Encoding.PEM,
277
- format=serialization.PrivateFormat.PKCS8,
278
- encryption_algorithm=serialization.NoEncryption(),
279
- ).decode("utf-8")
280
- else:
281
- self.key = None
282
-
283
- logger.debug(
284
- "📜🔑✅ Certificate.__attrs_post_init__: Generated cert and key"
285
- )
286
-
287
- else:
288
- if not self.cert_pem_or_uri:
289
- raise CertificateError(
290
- "cert_pem_or_uri required when not generating"
291
- )
292
-
293
- logger.debug("📜🔑🚀 Loading certificate from provided data")
294
- cert_data = self._load_from_uri_or_pem(self.cert_pem_or_uri)
295
- self.cert = cert_data
296
-
297
- logger.debug("📜🔑🔍 Loading X.509 certificate from PEM data")
298
- self._cert = x509.load_pem_x509_certificate(cert_data.encode("utf-8"))
299
- logger.debug("📜🔑✅ X.509 certificate object loaded from PEM")
300
-
301
- if self.key_pem_or_uri:
302
- logger.debug("📜🔑🚀 Loading private key")
303
- key_data = self._load_from_uri_or_pem(self.key_pem_or_uri)
304
- self.key = key_data
305
-
306
- loaded_priv_key = load_pem_private_key(
307
- key_data.encode("utf-8"), password=None
308
- )
309
- if not isinstance(
310
- loaded_priv_key,
311
- rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey,
312
- ):
313
- raise CertificateError(
314
- f"Loaded private key is of unsupported type: {type(loaded_priv_key)}. "
315
- "Expected RSA or ECDSA private key."
316
- )
317
- self._private_key = loaded_priv_key
318
- logger.debug("📜🔑✅ Private key object loaded and type validated")
319
- else:
320
- self.key = None
321
-
322
- loaded_not_valid_before = self._cert.not_valid_before_utc
323
- loaded_not_valid_after = self._cert.not_valid_after_utc
324
- if loaded_not_valid_before.tzinfo is None:
325
- loaded_not_valid_before = loaded_not_valid_before.replace(
326
- tzinfo=UTC
327
- )
328
- if loaded_not_valid_after.tzinfo is None:
329
- loaded_not_valid_after = loaded_not_valid_after.replace(tzinfo=UTC)
330
-
331
- cert_public_key = self._cert.public_key()
332
- if not isinstance(
333
- cert_public_key, rsa.RSAPublicKey | ec.EllipticCurvePublicKey
334
- ):
335
- raise CertificateError(
336
- f"Certificate's public key is of unsupported type: {type(cert_public_key)}. "
337
- "Expected RSA or ECDSA public key."
338
- )
339
-
340
- self._base = CertificateBase(
341
- subject=self._cert.subject,
342
- issuer=self._cert.issuer,
343
- public_key=cert_public_key,
344
- not_valid_before=loaded_not_valid_before,
345
- not_valid_after=loaded_not_valid_after,
346
- serial_number=self._cert.serial_number,
347
- )
348
- logger.debug("📜🔑✅ Reconstructed CertificateBase from loaded cert")
349
-
350
- except Exception as e:
351
- logger.error(
352
- f"📜❌ Certificate.__attrs_post_init__: Failed. Error: {type(e).__name__}: {e}",
353
- extra={"error": str(e), "trace": traceback.format_exc()},
354
- )
355
- raise CertificateError(
356
- f"Failed to initialize certificate. Original error: {type(e).__name__}"
357
- ) from e
358
-
359
- def _create_x509_certificate(
360
- self,
361
- issuer_name_override: "x509.Name | None" = None,
362
- signing_key_override: "KeyPair | None" = None,
363
- is_ca: bool = False,
364
- is_client_cert: bool = False,
365
- ) -> "X509Certificate":
366
- """Internal helper to build and sign the X.509 certificate object."""
367
- if not hasattr(self, "_base"):
368
- raise CertificateError("Cannot create certificate without base information")
369
-
370
- try:
371
- logger.debug("📜📝🚀 _create_x509_certificate: Building certificate")
372
-
373
- actual_issuer_name = (
374
- issuer_name_override if issuer_name_override else self._base.issuer
375
- )
376
- actual_signing_key = (
377
- signing_key_override if signing_key_override else self._private_key
378
- )
379
-
380
- if not actual_signing_key:
381
- raise CertificateError(
382
- "Cannot sign certificate without a signing key (either own or override)"
383
- )
384
-
385
- builder = (
386
- x509.CertificateBuilder()
387
- .subject_name(self._base.subject)
388
- .issuer_name(actual_issuer_name)
389
- .public_key(self._base.public_key)
390
- .serial_number(self._base.serial_number)
391
- .not_valid_before(self._base.not_valid_before)
392
- .not_valid_after(self._base.not_valid_after)
393
- )
394
-
395
- san_list = [x509.DNSName(name) for name in (self.alt_names or []) if name]
396
- if san_list:
397
- builder = builder.add_extension(
398
- x509.SubjectAlternativeName(san_list), critical=False
399
- )
400
- logger.debug(f"📜📝✅ Added SANs: {self.alt_names or []}")
401
-
402
- builder = builder.add_extension(
403
- x509.BasicConstraints(ca=is_ca, path_length=None),
404
- critical=True,
405
- )
406
-
407
- if is_ca:
408
- builder = builder.add_extension(
409
- x509.KeyUsage(
410
- digital_signature=False,
411
- key_encipherment=False,
412
- key_agreement=False,
413
- content_commitment=False,
414
- data_encipherment=False,
415
- key_cert_sign=True,
416
- crl_sign=True,
417
- encipher_only=False,
418
- decipher_only=False,
419
- ),
420
- critical=True,
421
- )
422
- else:
423
- builder = builder.add_extension(
424
- x509.KeyUsage(
425
- digital_signature=True,
426
- key_encipherment=(
427
- True
428
- if not is_client_cert
429
- and isinstance(self._base.public_key, rsa.RSAPublicKey)
430
- else False
431
- ),
432
- key_agreement=(
433
- True
434
- if isinstance(
435
- self._base.public_key, ec.EllipticCurvePublicKey
436
- )
437
- else False
438
- ),
439
- content_commitment=False,
440
- data_encipherment=False,
441
- key_cert_sign=False,
442
- crl_sign=False,
443
- encipher_only=False,
444
- decipher_only=False,
445
- ),
446
- critical=True,
447
- )
448
- extended_usages = []
449
- if is_client_cert:
450
- extended_usages.append(ExtendedKeyUsageOID.CLIENT_AUTH)
451
- else:
452
- extended_usages.append(ExtendedKeyUsageOID.SERVER_AUTH)
453
-
454
- if extended_usages:
455
- builder = builder.add_extension(
456
- x509.ExtendedKeyUsage(extended_usages),
457
- critical=False,
458
- )
459
-
460
- logger.debug(
461
- f"📜📝✅ Added BasicConstraints (is_ca={is_ca}), "
462
- f"KeyUsage, ExtendedKeyUsage (is_client_cert={is_client_cert})"
463
- )
464
-
465
- signed_cert = builder.sign(
466
- private_key=actual_signing_key,
467
- algorithm=hashes.SHA256(),
468
- backend=default_backend(),
469
- )
470
- logger.debug("📜📝✅ Certificate signed successfully")
471
- return signed_cert
472
-
473
- except Exception as e:
474
- logger.error(
475
- f"📜❌ _create_x509_certificate: Failed: {e}",
476
- extra={"error": str(e), "trace": traceback.format_exc()},
477
- )
478
- raise CertificateError("Failed to create X.509 certificate object") from e
479
-
480
- @staticmethod
481
- def _load_from_uri_or_pem(data: str) -> str:
482
- """Load PEM data either directly from a string or from a file URI."""
483
- try:
484
- if data.startswith("file://"):
485
- path_str = data.removeprefix("file://")
486
- if os.name == "nt" and path_str.startswith("//"):
487
- path = Path(path_str)
488
- else:
489
- path_str = path_str.lstrip("/")
490
- if os.name != "nt" and data.startswith("file:///"):
491
- path_str = "/" + path_str
492
- path = Path(path_str)
493
-
494
- logger.debug(f"📜📂🚀 Loading data from file: {path}")
495
- with path.open("r", encoding="utf-8") as f:
496
- loaded_data = f.read().strip()
497
- logger.debug("📜📂✅ Loaded data from file")
498
- return loaded_data
499
-
500
- loaded_data = data.strip()
501
- if not loaded_data.startswith("-----BEGIN"):
502
- logger.warning("📜📂⚠️ Data doesn't look like PEM format")
503
- return loaded_data
504
- except Exception as e:
505
- logger.error(f"📜📂❌ Failed to load data: {e}", extra={"error": str(e)})
506
- raise CertificateError(f"Failed to load data: {e}") from e
507
-
508
- # Properties
509
- @property
510
- def trust_chain(self) -> list["Certificate"]:
511
- """Returns the list of trusted certificates associated with this one."""
512
- return self._trust_chain
513
-
514
- @trust_chain.setter
515
- def trust_chain(self, value: list["Certificate"]) -> None:
516
- """Sets the list of trusted certificates."""
517
- self._trust_chain = value
518
-
519
- @cached_property
520
- def is_valid(self) -> bool:
521
- """Checks if the certificate is currently valid based on its dates."""
522
- if not hasattr(self, "_base"):
523
- return False
524
- now = datetime.now(UTC)
525
- valid = self._base.not_valid_before <= now <= self._base.not_valid_after
526
- return valid
527
-
528
- @property
529
- def is_ca(self) -> bool:
530
- """Checks if the certificate has the Basic Constraints CA flag set to True."""
531
- if not hasattr(self, "_cert"):
532
- return False
533
- try:
534
- ext = self._cert.extensions.get_extension_for_oid(
535
- x509.oid.ExtensionOID.BASIC_CONSTRAINTS
536
- )
537
- if isinstance(ext.value, x509.BasicConstraints):
538
- return ext.value.ca
539
- return False
540
- except x509.ExtensionNotFound:
541
- logger.debug("📜🔍⚠️ is_ca: Basic Constraints extension not found")
542
- return False
543
-
544
- @property
545
- def subject(self) -> str:
546
- """Returns the certificate subject as an RFC4514 string."""
547
- if not hasattr(self, "_base"):
548
- return "SubjectNotInitialized"
549
- return self._base.subject.rfc4514_string()
550
-
551
- @property
552
- def issuer(self) -> str:
553
- """Returns the certificate issuer as an RFC4514 string."""
554
- if not hasattr(self, "_base"):
555
- return "IssuerNotInitialized"
556
- return self._base.issuer.rfc4514_string()
557
-
558
- @property
559
- def public_key(self) -> "PublicKey | None":
560
- """Returns the public key object from the certificate."""
561
- if not hasattr(self, "_base"):
562
- return None
563
- return self._base.public_key
564
-
565
- @property
566
- def serial_number(self) -> int | None:
567
- """Returns the certificate serial number."""
568
- if not hasattr(self, "_base"):
569
- return None
570
- return self._base.serial_number
571
-
572
- @classmethod
573
- def create_ca(
574
- cls,
575
- common_name: str,
576
- organization_name: str,
577
- validity_days: int,
578
- key_type: str = DEFAULT_CERTIFICATE_KEY_TYPE,
579
- key_size: int = DEFAULT_RSA_KEY_SIZE,
580
- ecdsa_curve: str = DEFAULT_CERTIFICATE_CURVE,
581
- ) -> "Certificate":
582
- """Creates a new self-signed CA certificate."""
583
- logger.info(
584
- f"📜🔑🏭 Creating new CA certificate: CN={common_name}, Org={organization_name}"
585
- )
586
- ca_cert_obj = cls(
587
- generate_keypair=True,
588
- common_name=common_name,
589
- organization_name=organization_name,
590
- validity_days=validity_days,
591
- key_type=key_type,
592
- key_size=key_size,
593
- ecdsa_curve=ecdsa_curve,
594
- alt_names=[common_name],
595
- )
596
- # Re-sign to ensure CA flags are correctly set for a CA
597
- logger.info("📜🔑🏭 Re-signing generated CA certificate to ensure is_ca=True")
598
- actual_ca_x509_cert = ca_cert_obj._create_x509_certificate(
599
- is_ca=True,
600
- is_client_cert=False,
601
- )
602
- ca_cert_obj._cert = actual_ca_x509_cert
603
- ca_cert_obj.cert = actual_ca_x509_cert.public_bytes(
604
- serialization.Encoding.PEM
605
- ).decode("utf-8")
606
- return ca_cert_obj
607
-
608
- @classmethod
609
- def create_signed_certificate(
610
- cls,
611
- ca_certificate: "Certificate",
612
- common_name: str,
613
- organization_name: str,
614
- validity_days: int,
615
- alt_names: list[str] | None = None,
616
- key_type: str = DEFAULT_CERTIFICATE_KEY_TYPE,
617
- key_size: int = DEFAULT_RSA_KEY_SIZE,
618
- ecdsa_curve: str = DEFAULT_CERTIFICATE_CURVE,
619
- is_client_cert: bool = False,
620
- ) -> "Certificate":
621
- """Creates a new certificate signed by the provided CA certificate."""
622
- logger.info(
623
- f"📜🔑🏭 Creating new certificate signed by CA '{ca_certificate.subject}': "
624
- f"CN={common_name}, Org={organization_name}, ClientCert={is_client_cert}"
625
- )
626
- if not ca_certificate._private_key:
627
- raise CertificateError(
628
- message="CA certificate's private key is not available for signing.",
629
- hint="Ensure the CA certificate object was loaded or created with its private key.",
630
- )
631
- if not ca_certificate.is_ca:
632
- logger.warning(
633
- f"📜🔑⚠️ Signing certificate (Subject: {ca_certificate.subject}) "
634
- "is not marked as a CA. This might lead to validation issues."
635
- )
636
-
637
- new_cert_obj = cls(
638
- generate_keypair=True,
639
- common_name=common_name,
640
- organization_name=organization_name,
641
- validity_days=validity_days,
642
- alt_names=alt_names or [common_name],
643
- key_type=key_type,
644
- key_size=key_size,
645
- ecdsa_curve=ecdsa_curve,
646
- )
647
-
648
- signed_x509_cert = new_cert_obj._create_x509_certificate(
649
- issuer_name_override=ca_certificate._base.subject,
650
- signing_key_override=ca_certificate._private_key,
651
- is_ca=False,
652
- is_client_cert=is_client_cert,
653
- )
654
-
655
- new_cert_obj._cert = signed_x509_cert
656
- new_cert_obj.cert = signed_x509_cert.public_bytes(
657
- serialization.Encoding.PEM
658
- ).decode("utf-8")
659
-
660
- logger.info(
661
- f"📜🔑✅ Successfully created and signed certificate for "
662
- f"CN={common_name} by CA='{ca_certificate.subject}'"
663
- )
664
- return new_cert_obj
665
-
666
- @classmethod
667
- def create_self_signed_server_cert(
668
- cls,
669
- common_name: str,
670
- organization_name: str,
671
- validity_days: int,
672
- alt_names: list[str] | None = None,
673
- key_type: str = DEFAULT_CERTIFICATE_KEY_TYPE,
674
- key_size: int = DEFAULT_RSA_KEY_SIZE,
675
- ecdsa_curve: str = DEFAULT_CERTIFICATE_CURVE,
676
- ) -> "Certificate":
677
- """Creates a new self-signed end-entity certificate suitable for a server."""
678
- logger.info(
679
- f"📜🔑🏭 Creating new self-signed SERVER certificate: "
680
- f"CN={common_name}, Org={organization_name}"
681
- )
682
-
683
- cert_obj = cls(
684
- generate_keypair=True,
685
- common_name=common_name,
686
- organization_name=organization_name,
687
- validity_days=validity_days,
688
- alt_names=alt_names or [common_name],
689
- key_type=key_type,
690
- key_size=key_size,
691
- ecdsa_curve=ecdsa_curve,
692
- )
693
-
694
- if not cert_obj._private_key:
695
- raise CertificateError(
696
- "Private key not generated for self-signed server certificate"
697
- )
698
-
699
- actual_x509_cert = cert_obj._create_x509_certificate(
700
- is_ca=False,
701
- is_client_cert=False,
702
- )
703
-
704
- cert_obj._cert = actual_x509_cert
705
- cert_obj.cert = actual_x509_cert.public_bytes(
706
- serialization.Encoding.PEM
707
- ).decode("utf-8")
708
-
709
- logger.info(
710
- f"📜🔑✅ Successfully created self-signed SERVER certificate for CN={common_name}"
711
- )
712
- return cert_obj
713
-
714
- def verify_trust(self, other_cert: Self) -> bool:
715
- """Verifies if the `other_cert` is trusted based on this certificate's trust chain."""
716
- if other_cert is None:
717
- raise CertificateError("Cannot verify trust: other_cert is None")
718
-
719
- logger.debug(
720
- f"📜🔍🚀 Verifying trust for cert S/N {other_cert.serial_number} "
721
- f"against chain of S/N {self.serial_number}"
722
- )
723
-
724
- if not other_cert.is_valid:
725
- logger.debug(
726
- "📜🔍⚠️ Trust verification failed: Other certificate is not valid"
727
- )
728
- return False
729
- if not other_cert.public_key:
730
- raise CertificateError(
731
- "Cannot verify trust: Other certificate has no public key"
732
- )
733
-
734
- if self == other_cert:
735
- logger.debug(
736
- "📜🔍✅ Trust verified: Certificates are identical (based on subject/serial)"
737
- )
738
- return True
739
-
740
- if other_cert in self._trust_chain:
741
- logger.debug(
742
- "📜🔍✅ Trust verified: Other certificate found in trust chain"
743
- )
744
- return True
745
-
746
- for trusted_cert in self._trust_chain:
747
- logger.debug(
748
- f"📜🔍🔁 Checking signature against trusted cert S/N {trusted_cert.serial_number}"
749
- )
750
- if self._validate_signature(
751
- signed_cert=other_cert, signing_cert=trusted_cert
752
- ):
753
- logger.debug(
754
- f"📜🔍✅ Trust verified: Other cert signed by trusted cert S/N "
755
- f"{trusted_cert.serial_number}"
756
- )
757
- return True
758
-
759
- logger.debug(
760
- "📜🔍❌ Trust verification failed: Other certificate not identical, "
761
- "not in chain, and not signed by any cert in chain"
762
- )
763
- return False
764
-
765
- def _validate_signature(
766
- self, signed_cert: "Certificate", signing_cert: "Certificate"
767
- ) -> bool:
768
- """Internal helper: Validates signature and issuer/subject match."""
769
- if not hasattr(signed_cert, "_cert") or not hasattr(signing_cert, "_cert"):
770
- logger.error(
771
- "📜🔍❌ Cannot validate signature: Certificate object(s) not initialized"
772
- )
773
- return False
774
-
775
- if signed_cert._cert.issuer != signing_cert._cert.subject:
776
- logger.debug(
777
- f"📜🔍❌ Signature validation failed: Issuer/Subject mismatch. "
778
- f"Signed Issuer='{signed_cert._cert.issuer}', "
779
- f"Signing Subject='{signing_cert._cert.subject}'"
780
- )
781
- return False
782
-
783
- try:
784
- signing_public_key = signing_cert.public_key
785
- if not signing_public_key:
786
- logger.error(
787
- "📜🔍❌ Cannot validate signature: Signing certificate has no public key"
788
- )
789
- return False
790
-
791
- signature = signed_cert._cert.signature
792
- tbs_certificate_bytes = signed_cert._cert.tbs_certificate_bytes
793
- signature_hash_algorithm = signed_cert._cert.signature_hash_algorithm
794
-
795
- if not signature_hash_algorithm:
796
- logger.error("📜🔍❌ Cannot validate signature: Unknown hash algorithm")
797
- return False
798
-
799
- if isinstance(signing_public_key, rsa.RSAPublicKey):
800
- cast(rsa.RSAPublicKey, signing_public_key).verify(
801
- signature,
802
- tbs_certificate_bytes,
803
- padding.PKCS1v15(),
804
- signature_hash_algorithm,
805
- )
806
- elif isinstance(signing_public_key, ec.EllipticCurvePublicKey):
807
- cast(ec.EllipticCurvePublicKey, signing_public_key).verify(
808
- signature,
809
- tbs_certificate_bytes,
810
- ec.ECDSA(signature_hash_algorithm),
811
- )
812
- else:
813
- logger.error(
814
- f"📜🔍❌ Unsupported signing public key type: {type(signing_public_key)}"
815
- )
816
- return False
817
-
818
- return True
819
-
820
- except Exception as e:
821
- logger.debug(f"📜🔍❌ Signature validation failed: {type(e).__name__}: {e}")
822
- return False
823
-
824
- def __eq__(self, other: object) -> bool:
825
- """Custom equality based on subject and serial number."""
826
- if not isinstance(other, Certificate):
827
- return NotImplemented
828
- if not hasattr(self, "_base") or not hasattr(other, "_base"):
829
- return False
830
- eq = (
831
- self._base.subject == other._base.subject
832
- and self._base.serial_number == other._base.serial_number
833
- )
834
- return eq
835
-
836
- def __hash__(self) -> int:
837
- """Custom hash based on subject and serial number."""
838
- if not hasattr(self, "_base"):
839
- logger.warning("📜🔍⚠️ __hash__ called before _base initialized")
840
- return hash((None, None))
841
-
842
- h = hash((self._base.subject, self._base.serial_number))
843
- return h
844
-
845
- def __repr__(self) -> str:
846
- try:
847
- subject_str = self.subject
848
- issuer_str = self.issuer
849
- valid_str = str(self.is_valid)
850
- ca_str = str(self.is_ca)
851
- except AttributeError:
852
- subject_str = "PartiallyInitialized"
853
- issuer_str = "PartiallyInitialized"
854
- valid_str = "Unknown"
855
- ca_str = "Unknown"
856
-
857
- return (
858
- f"Certificate(subject='{subject_str}', issuer='{issuer_str}', "
859
- f"common_name='{self.common_name}', valid={valid_str}, ca={ca_str}, "
860
- f"key_type='{self.key_type}')"
861
- )
862
-
863
-
864
- # Convenience functions for common use cases
865
- def create_self_signed(
866
- common_name: str = "localhost",
867
- alt_names: list[str] | None = None,
868
- organization: str = "Default Organization",
869
- validity_days: int = DEFAULT_CERTIFICATE_VALIDITY_DAYS,
870
- key_type: str = DEFAULT_CERTIFICATE_KEY_TYPE,
871
- ) -> "Certificate":
872
- """Create a self-signed certificate (convenience function)."""
873
- _require_crypto()
874
- return Certificate.create_self_signed_server_cert(
875
- common_name=common_name,
876
- organization_name=organization,
877
- validity_days=validity_days,
878
- alt_names=alt_names or [common_name],
879
- key_type=key_type,
880
- )
881
-
882
-
883
- def create_ca(
884
- common_name: str,
885
- organization: str = "Default CA Organization",
886
- validity_days: int = DEFAULT_CERTIFICATE_VALIDITY_DAYS * 2, # CAs live longer
887
- key_type: str = DEFAULT_CERTIFICATE_KEY_TYPE,
888
- ) -> "Certificate":
889
- """Create a CA certificate (convenience function)."""
890
- _require_crypto()
891
- return Certificate.create_ca(
892
- common_name=common_name,
893
- organization_name=organization,
894
- validity_days=validity_days,
895
- key_type=key_type,
896
- )