tiny-ca 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiny_ca/__init__.py +41 -0
- tiny_ca/ca_factory/__init__.py +15 -0
- tiny_ca/ca_factory/factory.py +658 -0
- tiny_ca/ca_factory/utils/__init__.py +10 -0
- tiny_ca/ca_factory/utils/afile_loader.py +157 -0
- tiny_ca/ca_factory/utils/file_loader.py +290 -0
- tiny_ca/ca_factory/utils/life_time.py +199 -0
- tiny_ca/ca_factory/utils/serial.py +151 -0
- tiny_ca/const.py +81 -0
- tiny_ca/db/__init__.py +14 -0
- tiny_ca/db/async_db_manager.py +358 -0
- tiny_ca/db/base_db.py +173 -0
- tiny_ca/db/const.py +92 -0
- tiny_ca/db/models.py +144 -0
- tiny_ca/db/sync_db_manager.py +415 -0
- tiny_ca/exc.py +103 -0
- tiny_ca/managers/__init__.py +3 -0
- tiny_ca/managers/async_lifecycle_manager.py +702 -0
- tiny_ca/managers/sync_lifecycle_manager.py +722 -0
- tiny_ca/models/__init__.py +3 -0
- tiny_ca/models/certtificate.py +300 -0
- tiny_ca/settings.py +9 -0
- tiny_ca/storage/__init__.py +4 -0
- tiny_ca/storage/async_local_storage.py +218 -0
- tiny_ca/storage/base_storage.py +166 -0
- tiny_ca/storage/const.py +39 -0
- tiny_ca/storage/local_storage.py +448 -0
- tiny_ca/utils/__init__.py +0 -0
- tiny_ca/utils/serial_generator.py +530 -0
- tiny_ca-0.1.0.dist-info/METADATA +430 -0
- tiny_ca-0.1.0.dist-info/RECORD +33 -0
- tiny_ca-0.1.0.dist-info/WHEEL +5 -0
- tiny_ca-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""
|
|
2
|
+
factory.py
|
|
3
|
+
|
|
4
|
+
X.509 certificate factory: issues end-entity certificates, builds self-signed
|
|
5
|
+
CA certificates, generates CRLs, and validates certificate chains.
|
|
6
|
+
|
|
7
|
+
This module contains a single public class, ``CertificateFactory``, whose only
|
|
8
|
+
responsibility is cryptographic construction. It never touches the filesystem,
|
|
9
|
+
the database, or any business-logic layer.
|
|
10
|
+
|
|
11
|
+
SOLID notes
|
|
12
|
+
-----------
|
|
13
|
+
SRP : ``CertificateFactory`` handles cryptographic generation only.
|
|
14
|
+
File persistence → ``BaseStorage``. DB registration → ``BaseDB``.
|
|
15
|
+
OCP : New X.509 extension types are added in ``_build_extensions`` without
|
|
16
|
+
modifying any other method.
|
|
17
|
+
LSP : Any ``ICALoader`` implementation can be swapped in transparently.
|
|
18
|
+
ISP : ``SerialWithEncoding`` and ``CertLifetime`` are separate utility modules;
|
|
19
|
+
only the symbols needed here are imported.
|
|
20
|
+
DIP : CA material is provided through the ``ICALoader`` Protocol injected at
|
|
21
|
+
construction time; the factory never instantiates a loader itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import AsyncGenerator, Generator
|
|
27
|
+
import datetime
|
|
28
|
+
import ipaddress
|
|
29
|
+
from logging import Logger
|
|
30
|
+
import os
|
|
31
|
+
from typing import Any, cast
|
|
32
|
+
|
|
33
|
+
from cryptography import x509
|
|
34
|
+
from cryptography.hazmat.backends import default_backend
|
|
35
|
+
from cryptography.hazmat.primitives import hashes
|
|
36
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
37
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
38
|
+
from sqlalchemy import Row
|
|
39
|
+
|
|
40
|
+
from ..const import CertType
|
|
41
|
+
from ..db.models import CertificateRecord
|
|
42
|
+
from ..exc import ValidationCertError
|
|
43
|
+
from ..settings import DEFAULT_LOGGER
|
|
44
|
+
from ..utils.serial_generator import SerialWithEncoding
|
|
45
|
+
from .utils import CertLifetime, ICALoader
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CertificateFactory:
|
|
49
|
+
"""
|
|
50
|
+
Cryptographic factory for X.509 certificates, CSRs, and CRLs.
|
|
51
|
+
|
|
52
|
+
``CertificateFactory`` is the single source of all certificate-generation
|
|
53
|
+
logic in the library. It accepts an ``ICALoader`` at construction time and
|
|
54
|
+
uses the CA certificate and key it provides to sign all issued artefacts.
|
|
55
|
+
|
|
56
|
+
Responsibilities
|
|
57
|
+
----------------
|
|
58
|
+
- Generate self-signed root CA certificates (``build_self_signed_ca``).
|
|
59
|
+
- Issue end-entity certificates signed by the loaded CA (``issue_certificate``).
|
|
60
|
+
- Build and sign Certificate Revocation Lists (``build_crl``).
|
|
61
|
+
- Validate an existing certificate against the loaded CA (``validate_cert``).
|
|
62
|
+
|
|
63
|
+
Out of scope
|
|
64
|
+
------------
|
|
65
|
+
- Writing any files to disk.
|
|
66
|
+
- Recording certificates in a database.
|
|
67
|
+
- Business-level rules (duplicate CN detection, rotation policies, etc.).
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
ca_loader : ICALoader
|
|
72
|
+
Provider of the CA certificate, private key, and base Subject info.
|
|
73
|
+
Must satisfy the ``ICALoader`` Protocol (see ``file_loader.py``).
|
|
74
|
+
logger : Logger | None
|
|
75
|
+
Logger for operational messages. Falls back to ``DEFAULT_LOGGER``
|
|
76
|
+
when ``None``.
|
|
77
|
+
|
|
78
|
+
Raises
|
|
79
|
+
------
|
|
80
|
+
TypeError
|
|
81
|
+
If *ca_loader* does not implement the ``ICALoader`` Protocol.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
ca_loader: ICALoader,
|
|
87
|
+
logger: Logger | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
if not isinstance(ca_loader, ICALoader):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"ca_loader must implement ICALoader, got {type(ca_loader)}"
|
|
92
|
+
)
|
|
93
|
+
self._ca = ca_loader
|
|
94
|
+
self._logger = logger or DEFAULT_LOGGER
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Public API
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def build_self_signed_ca(
|
|
102
|
+
common_name: str = "Internal CA",
|
|
103
|
+
organization: str = "My Company",
|
|
104
|
+
country: str = "UA",
|
|
105
|
+
key_size: int = 2048,
|
|
106
|
+
days_valid: int = 3650,
|
|
107
|
+
valid_from: datetime.datetime | None = None,
|
|
108
|
+
logger: Logger | None = None,
|
|
109
|
+
) -> tuple[x509.Certificate, rsa.RSAPrivateKey]:
|
|
110
|
+
"""
|
|
111
|
+
Generate a self-signed root CA certificate and its private key.
|
|
112
|
+
|
|
113
|
+
This is a ``@staticmethod`` — it requires no loaded CA because the
|
|
114
|
+
resulting certificate is its own issuer. It is typically called once
|
|
115
|
+
during bootstrap to establish the trust anchor for the PKI.
|
|
116
|
+
|
|
117
|
+
The generated certificate includes:
|
|
118
|
+
- ``BasicConstraints(ca=True)`` — marks it as a CA certificate.
|
|
119
|
+
- ``KeyUsage`` with ``key_cert_sign`` and ``crl_sign`` set to ``True``.
|
|
120
|
+
- ``SubjectKeyIdentifier`` derived from the public key.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
common_name : str
|
|
125
|
+
Common Name (CN) for the CA Subject / Issuer fields.
|
|
126
|
+
Default: ``"Internal CA"``.
|
|
127
|
+
organization : str
|
|
128
|
+
Organization (O) field. Default: ``"My Company"``.
|
|
129
|
+
country : str
|
|
130
|
+
Two-letter ISO 3166-1 alpha-2 country code (C field).
|
|
131
|
+
Default: ``"UA"``.
|
|
132
|
+
key_size : int
|
|
133
|
+
RSA key length in bits. Use ``2048`` for standard security or
|
|
134
|
+
``4096`` for long-lived roots. Default: ``2048``.
|
|
135
|
+
days_valid : int
|
|
136
|
+
Validity period in calendar days. Default: ``3650`` (≈10 years).
|
|
137
|
+
valid_from : datetime.datetime | None
|
|
138
|
+
Start of the validity period. ``None`` uses the current UTC time.
|
|
139
|
+
logger : Logger | None
|
|
140
|
+
Optional logger. Falls back to ``DEFAULT_LOGGER``.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
tuple[x509.Certificate, rsa.RSAPrivateKey]
|
|
145
|
+
``(certificate, private_key)`` — both must be persisted by the caller.
|
|
146
|
+
|
|
147
|
+
Raises
|
|
148
|
+
------
|
|
149
|
+
InvalidRangeTimeCertificate
|
|
150
|
+
If the computed expiry date is already in the past.
|
|
151
|
+
"""
|
|
152
|
+
_log = logger or DEFAULT_LOGGER
|
|
153
|
+
_log.info(
|
|
154
|
+
"Generating CA certificate: CN=%s, key_size=%d", common_name, key_size
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
private_key = rsa.generate_private_key(
|
|
158
|
+
public_exponent=65537,
|
|
159
|
+
key_size=key_size,
|
|
160
|
+
backend=default_backend(),
|
|
161
|
+
)
|
|
162
|
+
subject = issuer = x509.Name(
|
|
163
|
+
[
|
|
164
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, country),
|
|
165
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
|
|
166
|
+
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
|
167
|
+
]
|
|
168
|
+
)
|
|
169
|
+
valid_from_dt, valid_to_dt = CertLifetime.compute(valid_from, days_valid)
|
|
170
|
+
serial_number = SerialWithEncoding.generate(
|
|
171
|
+
name=common_name.lower().replace(os.sep, "_"),
|
|
172
|
+
serial_type=CertType.CA,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
certificate = (
|
|
176
|
+
x509.CertificateBuilder()
|
|
177
|
+
.subject_name(subject)
|
|
178
|
+
.issuer_name(issuer)
|
|
179
|
+
.public_key(private_key.public_key())
|
|
180
|
+
.serial_number(serial_number)
|
|
181
|
+
.not_valid_before(valid_from_dt)
|
|
182
|
+
.not_valid_after(valid_to_dt)
|
|
183
|
+
.add_extension(
|
|
184
|
+
x509.BasicConstraints(ca=True, path_length=None), critical=True
|
|
185
|
+
)
|
|
186
|
+
.add_extension(
|
|
187
|
+
x509.KeyUsage(
|
|
188
|
+
digital_signature=False,
|
|
189
|
+
key_encipherment=False,
|
|
190
|
+
content_commitment=False,
|
|
191
|
+
data_encipherment=False,
|
|
192
|
+
key_agreement=False,
|
|
193
|
+
key_cert_sign=True,
|
|
194
|
+
crl_sign=True,
|
|
195
|
+
encipher_only=False,
|
|
196
|
+
decipher_only=False,
|
|
197
|
+
),
|
|
198
|
+
critical=True,
|
|
199
|
+
)
|
|
200
|
+
.add_extension(
|
|
201
|
+
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
|
|
202
|
+
critical=False,
|
|
203
|
+
)
|
|
204
|
+
.sign(private_key, hashes.SHA256(), backend=default_backend())
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
_log.info(
|
|
208
|
+
"CA certificate created: subject=%s, valid_until=%s",
|
|
209
|
+
subject.rfc4514_string(),
|
|
210
|
+
valid_to_dt.isoformat(),
|
|
211
|
+
)
|
|
212
|
+
return certificate, private_key
|
|
213
|
+
|
|
214
|
+
def issue_certificate(
|
|
215
|
+
self,
|
|
216
|
+
common_name: str,
|
|
217
|
+
serial_type: CertType = CertType.SERVICE,
|
|
218
|
+
key_size: int = 2048,
|
|
219
|
+
days_valid: int = 365,
|
|
220
|
+
valid_from: datetime.datetime | None = None,
|
|
221
|
+
email: str | None = None,
|
|
222
|
+
is_server_cert: bool = False,
|
|
223
|
+
is_client_cert: bool = False,
|
|
224
|
+
san_dns: list[str] | None = None,
|
|
225
|
+
san_ip: list[str] | None = None,
|
|
226
|
+
) -> tuple[x509.Certificate, rsa.RSAPrivateKey, x509.CertificateSigningRequest]:
|
|
227
|
+
"""
|
|
228
|
+
Issue a signed end-entity certificate for the given subject parameters.
|
|
229
|
+
|
|
230
|
+
Workflow:
|
|
231
|
+
1. Generate a fresh RSA key pair.
|
|
232
|
+
2. Build the Subject ``x509.Name`` from CA base info + *common_name* / *email*.
|
|
233
|
+
3. Create a CSR signed with the new private key.
|
|
234
|
+
4. Assemble X.509 extensions (KeyUsage, EKU, SAN, SKI, AKI).
|
|
235
|
+
5. Sign the certificate with the CA key from ``self._ca``.
|
|
236
|
+
|
|
237
|
+
The Subject inherits *country* and *organization* from the CA's own
|
|
238
|
+
certificate so that all issued certificates share a consistent issuer
|
|
239
|
+
hierarchy.
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
common_name : str
|
|
244
|
+
Common Name (CN) for the new certificate's Subject.
|
|
245
|
+
serial_type : CertType
|
|
246
|
+
Certificate category used when encoding the serial number.
|
|
247
|
+
Default: ``CertType.SERVICE``.
|
|
248
|
+
key_size : int
|
|
249
|
+
RSA key length in bits. Default: ``2048``.
|
|
250
|
+
days_valid : int
|
|
251
|
+
Validity period in calendar days. Default: ``365``.
|
|
252
|
+
valid_from : datetime.datetime | None
|
|
253
|
+
Start of the validity period. ``None`` uses the current UTC time.
|
|
254
|
+
email : str | None
|
|
255
|
+
Optional email address added as an ``emailAddress`` Subject attribute.
|
|
256
|
+
is_server_cert : bool
|
|
257
|
+
When ``True``, adds ``ServerAuth`` to the Extended Key Usage
|
|
258
|
+
extension and includes *common_name* as a DNS SAN (RFC 2818
|
|
259
|
+
compliance).
|
|
260
|
+
is_client_cert : bool
|
|
261
|
+
When ``True``, adds ``ClientAuth`` to the Extended Key Usage
|
|
262
|
+
extension.
|
|
263
|
+
san_dns : list[str] | None
|
|
264
|
+
Additional DNS names for the Subject Alternative Name extension.
|
|
265
|
+
san_ip : list[str] | None
|
|
266
|
+
IP addresses (as strings) for the Subject Alternative Name extension.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
tuple[x509.Certificate, rsa.RSAPrivateKey, x509.CertificateSigningRequest]
|
|
271
|
+
``(certificate, private_key, csr)`` — the certificate and key must
|
|
272
|
+
be persisted by the caller; the CSR is returned for audit purposes.
|
|
273
|
+
|
|
274
|
+
Raises
|
|
275
|
+
------
|
|
276
|
+
InvalidRangeTimeCertificate
|
|
277
|
+
If the computed expiry date is already in the past.
|
|
278
|
+
"""
|
|
279
|
+
self._logger.info(
|
|
280
|
+
"Issuing certificate: CN=%s, type=%s, server=%s, client=%s",
|
|
281
|
+
common_name,
|
|
282
|
+
serial_type,
|
|
283
|
+
is_server_cert,
|
|
284
|
+
is_client_cert,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
private_key = rsa.generate_private_key(
|
|
288
|
+
public_exponent=65537,
|
|
289
|
+
key_size=key_size,
|
|
290
|
+
backend=default_backend(),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
subject = self._build_subject(common_name, email)
|
|
294
|
+
csr = self._build_csr(subject, private_key)
|
|
295
|
+
extensions = self._build_extensions(
|
|
296
|
+
csr, is_server_cert, is_client_cert, san_dns, san_ip
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
valid_from_dt, valid_to_dt = CertLifetime.compute(valid_from, days_valid)
|
|
300
|
+
serial_number = SerialWithEncoding.generate(
|
|
301
|
+
name=common_name.lower().replace(os.sep, "_"),
|
|
302
|
+
serial_type=serial_type,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
builder = (
|
|
306
|
+
x509.CertificateBuilder()
|
|
307
|
+
.subject_name(subject)
|
|
308
|
+
.issuer_name(self._ca.ca_cert.subject)
|
|
309
|
+
.public_key(csr.public_key())
|
|
310
|
+
.serial_number(serial_number)
|
|
311
|
+
.not_valid_before(valid_from_dt)
|
|
312
|
+
.not_valid_after(valid_to_dt)
|
|
313
|
+
)
|
|
314
|
+
for ext, critical in extensions:
|
|
315
|
+
builder = builder.add_extension(ext, critical=critical)
|
|
316
|
+
|
|
317
|
+
certificate = builder.sign(self._ca.ca_key, hashes.SHA256(), default_backend())
|
|
318
|
+
|
|
319
|
+
self._logger.info(
|
|
320
|
+
"Certificate issued: CN=%s, serial=%s, valid_until=%s",
|
|
321
|
+
common_name,
|
|
322
|
+
certificate.serial_number,
|
|
323
|
+
valid_to_dt.isoformat(),
|
|
324
|
+
)
|
|
325
|
+
return certificate, private_key, csr
|
|
326
|
+
|
|
327
|
+
def build_crl(
|
|
328
|
+
self,
|
|
329
|
+
revoked_certs: Generator[CertificateRecord, None, None],
|
|
330
|
+
days_valid: int = 1,
|
|
331
|
+
) -> x509.CertificateRevocationList:
|
|
332
|
+
"""
|
|
333
|
+
Build and sign a Certificate Revocation List from the provided records.
|
|
334
|
+
|
|
335
|
+
Iterates over *revoked_certs*, adds each entry to the CRL builder, then
|
|
336
|
+
signs the list with the CA private key. The resulting CRL is valid from
|
|
337
|
+
the current UTC time until ``now + days_valid`` days.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
revoked_certs : Generator[CertificateRecord, None, None]
|
|
342
|
+
Iterable of revoked certificate records as returned by
|
|
343
|
+
``BaseDB.get_revoked_certificates``. Each record must expose
|
|
344
|
+
``serial_number`` (castable to ``int``) and ``revocation_date``
|
|
345
|
+
(a ``datetime`` object).
|
|
346
|
+
days_valid : int
|
|
347
|
+
Number of days until the CRL expires and must be regenerated.
|
|
348
|
+
Typical values are ``1`` (daily rotation) to ``7`` (weekly).
|
|
349
|
+
Default: ``1``.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
x509.CertificateRevocationList
|
|
354
|
+
The signed CRL object. The caller is responsible for persisting it
|
|
355
|
+
to storage via ``BaseStorage``.
|
|
356
|
+
"""
|
|
357
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
358
|
+
self._logger.info(
|
|
359
|
+
"Building CRL: valid until %s",
|
|
360
|
+
(now + datetime.timedelta(days=days_valid)).isoformat(),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
builder = (
|
|
364
|
+
x509.CertificateRevocationListBuilder()
|
|
365
|
+
.issuer_name(self._ca.ca_cert.subject)
|
|
366
|
+
.last_update(now)
|
|
367
|
+
.next_update(now + datetime.timedelta(days=days_valid))
|
|
368
|
+
)
|
|
369
|
+
for record in revoked_certs:
|
|
370
|
+
revoked = (
|
|
371
|
+
x509.RevokedCertificateBuilder()
|
|
372
|
+
.serial_number(int(record.serial_number))
|
|
373
|
+
.revocation_date(record.revocation_date) # type: ignore
|
|
374
|
+
.build()
|
|
375
|
+
)
|
|
376
|
+
builder = builder.add_revoked_certificate(revoked)
|
|
377
|
+
|
|
378
|
+
crl = builder.sign(self._ca.ca_key, hashes.SHA256(), default_backend())
|
|
379
|
+
self._logger.debug("CRL signed successfully")
|
|
380
|
+
return crl
|
|
381
|
+
|
|
382
|
+
async def abuild_crl(
|
|
383
|
+
self,
|
|
384
|
+
revoked_certs: AsyncGenerator[Row[tuple[str, datetime.datetime, str]], None],
|
|
385
|
+
days_valid: int = 1,
|
|
386
|
+
) -> x509.CertificateRevocationList:
|
|
387
|
+
|
|
388
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
389
|
+
self._logger.info(
|
|
390
|
+
"Building CRL: valid until %s",
|
|
391
|
+
(now + datetime.timedelta(days=days_valid)).isoformat(),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
builder = (
|
|
395
|
+
x509.CertificateRevocationListBuilder()
|
|
396
|
+
.issuer_name(self._ca.ca_cert.subject)
|
|
397
|
+
.last_update(now)
|
|
398
|
+
.next_update(now + datetime.timedelta(days=days_valid))
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async for record in revoked_certs:
|
|
402
|
+
revoked = (
|
|
403
|
+
x509.RevokedCertificateBuilder()
|
|
404
|
+
.serial_number(int(record[0]))
|
|
405
|
+
.revocation_date(record[1]) # type: ignore
|
|
406
|
+
.build()
|
|
407
|
+
)
|
|
408
|
+
builder = builder.add_revoked_certificate(revoked)
|
|
409
|
+
crl = builder.sign(self._ca.ca_key, hashes.SHA256(), default_backend())
|
|
410
|
+
self._logger.debug("CRL signed successfully")
|
|
411
|
+
return crl
|
|
412
|
+
|
|
413
|
+
def validate_cert(self, cert: x509.Certificate) -> None:
|
|
414
|
+
"""
|
|
415
|
+
Verify that *cert* was issued by this CA, is within its validity window,
|
|
416
|
+
and carries a cryptographically correct signature.
|
|
417
|
+
|
|
418
|
+
Three checks are performed in order:
|
|
419
|
+
1. **Issuer match** — ``cert.issuer`` must equal the CA's Subject.
|
|
420
|
+
2. **Validity window** — current UTC time must be between
|
|
421
|
+
``cert.not_valid_before_utc`` and ``cert.not_valid_after_utc``.
|
|
422
|
+
3. **Signature** — the CA public key is used to verify the certificate
|
|
423
|
+
signature using PKCS#1 v1.5 with the algorithm declared in the cert.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
cert : x509.Certificate
|
|
428
|
+
The certificate to validate.
|
|
429
|
+
|
|
430
|
+
Returns
|
|
431
|
+
-------
|
|
432
|
+
None
|
|
433
|
+
Returns silently when all checks pass.
|
|
434
|
+
|
|
435
|
+
Raises
|
|
436
|
+
------
|
|
437
|
+
ValidationCertError
|
|
438
|
+
If any of the three checks fails. The message describes which
|
|
439
|
+
check failed and includes the relevant values (timestamps, issuer).
|
|
440
|
+
"""
|
|
441
|
+
self._logger.debug("Validating certificate serial=%s", cert.serial_number)
|
|
442
|
+
|
|
443
|
+
if cert.issuer != self._ca.ca_cert.subject:
|
|
444
|
+
raise ValidationCertError(
|
|
445
|
+
"Certificate issuer does not match the CA subject."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
449
|
+
not_before = cert.not_valid_before_utc
|
|
450
|
+
not_after = cert.not_valid_after_utc
|
|
451
|
+
if not_before > now or not_after < now:
|
|
452
|
+
raise ValidationCertError(
|
|
453
|
+
f"Certificate is outside its validity window: "
|
|
454
|
+
f"not_before={not_before.isoformat()}, "
|
|
455
|
+
f"not_after={not_after.isoformat()}, "
|
|
456
|
+
f"now={now.isoformat()}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
self._ca.ca_cert.public_key().verify( # type: ignore
|
|
461
|
+
cert.signature,
|
|
462
|
+
cert.tbs_certificate_bytes,
|
|
463
|
+
padding.PKCS1v15(), # type: ignore
|
|
464
|
+
cert.signature_hash_algorithm,
|
|
465
|
+
)
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
raise ValidationCertError(f"Signature verification failed: {exc}") from exc
|
|
468
|
+
|
|
469
|
+
self._logger.debug(
|
|
470
|
+
"Certificate serial=%s passed all validation checks", cert.serial_number
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Private helpers
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def _build_subject(self, common_name: str, email: str | None) -> x509.Name:
|
|
478
|
+
"""
|
|
479
|
+
Construct the Subject ``x509.Name`` for a new end-entity certificate.
|
|
480
|
+
|
|
481
|
+
Attributes are populated in the following order (absent CA attributes
|
|
482
|
+
are skipped):
|
|
483
|
+
1. Country (C) — inherited from the CA's base info.
|
|
484
|
+
2. Organization (O) — inherited from the CA's base info.
|
|
485
|
+
3. Common Name (CN) — always present, taken from *common_name*.
|
|
486
|
+
4. Email Address — only if *email* is not ``None``.
|
|
487
|
+
|
|
488
|
+
Parameters
|
|
489
|
+
----------
|
|
490
|
+
common_name : str
|
|
491
|
+
CN value for the new certificate's Subject.
|
|
492
|
+
email : str | None
|
|
493
|
+
Optional email address to include as an ``emailAddress`` attribute.
|
|
494
|
+
|
|
495
|
+
Returns
|
|
496
|
+
-------
|
|
497
|
+
x509.Name
|
|
498
|
+
Fully constructed Subject name ready for use in a certificate builder.
|
|
499
|
+
"""
|
|
500
|
+
attrs: list[x509.NameAttribute[Any]] = []
|
|
501
|
+
info = self._ca.base_info
|
|
502
|
+
|
|
503
|
+
if info.country:
|
|
504
|
+
attrs.append(x509.NameAttribute(NameOID.COUNTRY_NAME, info.country))
|
|
505
|
+
if info.organization:
|
|
506
|
+
attrs.append(
|
|
507
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, info.organization)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
attrs.append(x509.NameAttribute(NameOID.COMMON_NAME, common_name))
|
|
511
|
+
|
|
512
|
+
if email:
|
|
513
|
+
attrs.append(x509.NameAttribute(NameOID.EMAIL_ADDRESS, email))
|
|
514
|
+
|
|
515
|
+
return x509.Name(attrs)
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _build_csr(
|
|
519
|
+
subject: x509.Name,
|
|
520
|
+
private_key: rsa.RSAPrivateKey,
|
|
521
|
+
) -> x509.CertificateSigningRequest:
|
|
522
|
+
"""
|
|
523
|
+
Create a Certificate Signing Request for *subject* signed with *private_key*.
|
|
524
|
+
|
|
525
|
+
The CSR is self-signed (the applicant proves possession of the private
|
|
526
|
+
key) using SHA-256. It carries no extensions — extensions are added to
|
|
527
|
+
the final certificate by the CA, not the CSR.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
subject : x509.Name
|
|
532
|
+
The Subject name to embed in the CSR.
|
|
533
|
+
private_key : rsa.RSAPrivateKey
|
|
534
|
+
The private key corresponding to the public key that will appear
|
|
535
|
+
in the issued certificate.
|
|
536
|
+
|
|
537
|
+
Returns
|
|
538
|
+
-------
|
|
539
|
+
x509.CertificateSigningRequest
|
|
540
|
+
The signed CSR, retained for audit purposes and passed to the
|
|
541
|
+
certificate builder to copy the public key.
|
|
542
|
+
"""
|
|
543
|
+
return (
|
|
544
|
+
x509.CertificateSigningRequestBuilder()
|
|
545
|
+
.subject_name(subject)
|
|
546
|
+
.sign(private_key, hashes.SHA256(), backend=default_backend())
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _build_extensions(
|
|
550
|
+
self,
|
|
551
|
+
csr: x509.CertificateSigningRequest,
|
|
552
|
+
is_server_cert: bool,
|
|
553
|
+
is_client_cert: bool,
|
|
554
|
+
san_dns: list[str] | None,
|
|
555
|
+
san_ip: list[str] | None,
|
|
556
|
+
) -> list[tuple[x509.ExtensionType, bool]]:
|
|
557
|
+
"""
|
|
558
|
+
Assemble the list of X.509 v3 extensions for a new end-entity certificate.
|
|
559
|
+
|
|
560
|
+
Always-present extensions
|
|
561
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
562
|
+
- ``BasicConstraints(ca=False)`` — critical; marks this as a leaf cert.
|
|
563
|
+
- ``KeyUsage(digital_signature=True, key_encipherment=True)`` — critical.
|
|
564
|
+
|
|
565
|
+
Conditionally-added extensions
|
|
566
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
567
|
+
- ``ExtendedKeyUsage`` — added when at least one of *is_server_cert*
|
|
568
|
+
or *is_client_cert* is ``True``; contains ``ServerAuth`` and/or
|
|
569
|
+
``ClientAuth`` OIDs accordingly.
|
|
570
|
+
- ``SubjectAlternativeName`` — added when any SAN entries exist.
|
|
571
|
+
When *is_server_cert* is ``True`` the CN is automatically prepended
|
|
572
|
+
as a DNS SAN for RFC 2818 compliance. Additional DNS names and IP
|
|
573
|
+
addresses from *san_dns* / *san_ip* are appended.
|
|
574
|
+
- ``SubjectKeyIdentifier`` — always added; derived from the CSR public key.
|
|
575
|
+
- ``AuthorityKeyIdentifier`` — always added; derived from the CA's own
|
|
576
|
+
``SubjectKeyIdentifier`` extension.
|
|
577
|
+
|
|
578
|
+
Parameters
|
|
579
|
+
----------
|
|
580
|
+
csr : x509.CertificateSigningRequest
|
|
581
|
+
The CSR whose public key is used to derive ``SubjectKeyIdentifier``.
|
|
582
|
+
is_server_cert : bool
|
|
583
|
+
Include ``ServerAuth`` EKU and add CN as a DNS SAN.
|
|
584
|
+
is_client_cert : bool
|
|
585
|
+
Include ``ClientAuth`` EKU.
|
|
586
|
+
san_dns : list[str] | None
|
|
587
|
+
Extra DNS names for the SAN extension.
|
|
588
|
+
san_ip : list[str] | None
|
|
589
|
+
IP address strings (e.g. ``"192.168.1.1"``) for the SAN extension.
|
|
590
|
+
|
|
591
|
+
Returns
|
|
592
|
+
-------
|
|
593
|
+
list[tuple[x509.ExtensionType, bool]]
|
|
594
|
+
Ordered list of ``(extension_object, is_critical)`` pairs ready to
|
|
595
|
+
be passed to ``CertificateBuilder.add_extension``.
|
|
596
|
+
"""
|
|
597
|
+
result: list[tuple[x509.ExtensionType, bool]] = [
|
|
598
|
+
(x509.BasicConstraints(ca=False, path_length=None), True),
|
|
599
|
+
(
|
|
600
|
+
x509.KeyUsage(
|
|
601
|
+
digital_signature=True,
|
|
602
|
+
key_encipherment=True,
|
|
603
|
+
content_commitment=False,
|
|
604
|
+
data_encipherment=False,
|
|
605
|
+
key_agreement=False,
|
|
606
|
+
key_cert_sign=False,
|
|
607
|
+
crl_sign=False,
|
|
608
|
+
encipher_only=False,
|
|
609
|
+
decipher_only=False,
|
|
610
|
+
),
|
|
611
|
+
True,
|
|
612
|
+
),
|
|
613
|
+
]
|
|
614
|
+
|
|
615
|
+
# Extended Key Usage — only added when at least one usage flag is set.
|
|
616
|
+
eku: list[x509.ObjectIdentifier] = []
|
|
617
|
+
if is_server_cert:
|
|
618
|
+
eku.append(ExtendedKeyUsageOID.SERVER_AUTH)
|
|
619
|
+
if is_client_cert:
|
|
620
|
+
eku.append(ExtendedKeyUsageOID.CLIENT_AUTH)
|
|
621
|
+
if eku:
|
|
622
|
+
result.append((x509.ExtendedKeyUsage(eku), False))
|
|
623
|
+
|
|
624
|
+
# Subject Alternative Names.
|
|
625
|
+
# For server certificates the CN is included per RFC 2818 §3.1 which
|
|
626
|
+
# requires modern TLS clients to check SANs rather than the CN.
|
|
627
|
+
san: list[x509.GeneralName] = []
|
|
628
|
+
if is_server_cert:
|
|
629
|
+
try:
|
|
630
|
+
cn = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
|
631
|
+
san.append(
|
|
632
|
+
x509.DNSName(cn if isinstance(cn, str) else cn.decode("utf-8"))
|
|
633
|
+
)
|
|
634
|
+
except IndexError: # pragma: no cover
|
|
635
|
+
pass
|
|
636
|
+
if san_dns:
|
|
637
|
+
san.extend(x509.DNSName(d) for d in san_dns)
|
|
638
|
+
if san_ip:
|
|
639
|
+
san.extend(x509.IPAddress(ipaddress.ip_address(ip)) for ip in san_ip)
|
|
640
|
+
if san:
|
|
641
|
+
result.append((x509.SubjectAlternativeName(san), is_server_cert))
|
|
642
|
+
|
|
643
|
+
# Key identifiers — required by RFC 5280 §4.2.1.2 and §4.2.1.1.
|
|
644
|
+
result.append(
|
|
645
|
+
(x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), False)
|
|
646
|
+
)
|
|
647
|
+
result.append(
|
|
648
|
+
(
|
|
649
|
+
x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
|
|
650
|
+
self._ca.ca_cert.extensions.get_extension_for_class(
|
|
651
|
+
x509.SubjectKeyIdentifier
|
|
652
|
+
).value
|
|
653
|
+
),
|
|
654
|
+
False,
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return result
|