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,722 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sync_lifecycle_manager.py
|
|
3
|
+
|
|
4
|
+
High-level orchestrator for the X.509 certificate lifecycle.
|
|
5
|
+
|
|
6
|
+
This module is the single entry-point for application code that needs to
|
|
7
|
+
issue, store, revoke, rotate, or verify certificates. It delegates every
|
|
8
|
+
specialised concern to the appropriate collaborator:
|
|
9
|
+
|
|
10
|
+
- Cryptographic generation → ``CertificateFactory``
|
|
11
|
+
- File persistence → ``BaseStorage``
|
|
12
|
+
- Database registration → ``BaseDB``
|
|
13
|
+
|
|
14
|
+
SOLID notes
|
|
15
|
+
-----------
|
|
16
|
+
SRP : ``CertLifecycleManager`` coordinates operations; it contains no
|
|
17
|
+
cryptographic logic, no SQL, and no file I/O of its own.
|
|
18
|
+
OCP : New storage backends and database adapters plug in without changing
|
|
19
|
+
this class.
|
|
20
|
+
LSP : ``BaseStorage`` and ``BaseDB`` dependencies are fully substitutable.
|
|
21
|
+
ISP : Interfaces are narrow; callers that only need issuance never interact
|
|
22
|
+
with revocation internals.
|
|
23
|
+
DIP : All three collaborators are injected at construction time; the manager
|
|
24
|
+
never instantiates them directly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from logging import Logger
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from cryptography import x509
|
|
35
|
+
|
|
36
|
+
from tiny_ca.ca_factory import CertificateFactory
|
|
37
|
+
from tiny_ca.const import CertType
|
|
38
|
+
from tiny_ca.db.base_db import BaseDB
|
|
39
|
+
from tiny_ca.db.const import CertificateStatus
|
|
40
|
+
from tiny_ca.exc import (
|
|
41
|
+
CertNotFound,
|
|
42
|
+
DBNotInitedError,
|
|
43
|
+
NotUniqueCertOwner,
|
|
44
|
+
ValidationCertError,
|
|
45
|
+
)
|
|
46
|
+
from tiny_ca.models.certtificate import CAConfig, ClientConfig
|
|
47
|
+
from tiny_ca.settings import DEFAULT_LOGGER
|
|
48
|
+
from tiny_ca.storage import BaseStorage, LocalStorage
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CertLifecycleManager:
|
|
52
|
+
"""
|
|
53
|
+
Orchestrates the full lifecycle of X.509 certificates.
|
|
54
|
+
|
|
55
|
+
``CertLifecycleManager`` is the facade that application code interacts with.
|
|
56
|
+
It coordinates four collaborators through dependency injection:
|
|
57
|
+
|
|
58
|
+
- ``CertificateFactory`` — cryptographic generation of certificates and CRLs.
|
|
59
|
+
- ``BaseStorage`` — persistent storage of PEM/key/CSR/CRL files.
|
|
60
|
+
- ``BaseDB`` — registration, lookup, revocation, and rotation
|
|
61
|
+
records in a relational database.
|
|
62
|
+
- ``Logger`` — structured operational logging.
|
|
63
|
+
|
|
64
|
+
All three external collaborators are optional at construction time, but
|
|
65
|
+
specific operations will raise ``ValueError`` or ``DBNotInitedError`` if
|
|
66
|
+
a required collaborator is absent when that operation is invoked.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
storage : BaseStorage
|
|
71
|
+
File storage backend. Defaults to ``LocalStorage()`` which writes to
|
|
72
|
+
``./certs`` relative to the working directory.
|
|
73
|
+
factory : CertificateFactory | None
|
|
74
|
+
Cryptographic factory. Must be set before calling
|
|
75
|
+
``issue_certificate``, ``generate_crl``, or ``verify_certificate``.
|
|
76
|
+
Can be set after construction via the ``factory`` property setter.
|
|
77
|
+
db_handler : BaseDB | None
|
|
78
|
+
Database adapter. When ``None``, all operations that require
|
|
79
|
+
persistence (status lookup, revocation, rotation) will raise
|
|
80
|
+
``DBNotInitedError``.
|
|
81
|
+
logger : Logger | None
|
|
82
|
+
Logger for operational messages. Falls back to ``DEFAULT_LOGGER``.
|
|
83
|
+
|
|
84
|
+
Raises
|
|
85
|
+
------
|
|
86
|
+
TypeError
|
|
87
|
+
If *storage* is not a ``BaseStorage`` instance.
|
|
88
|
+
TypeError
|
|
89
|
+
If *db_handler* is provided but is not a ``BaseDB`` instance.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
storage: BaseStorage = LocalStorage(),
|
|
95
|
+
factory: CertificateFactory | None = None,
|
|
96
|
+
db_handler: BaseDB | None = None,
|
|
97
|
+
logger: Logger | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not isinstance(storage, BaseStorage):
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"storage must be a BaseStorage instance, got {type(storage)}"
|
|
102
|
+
)
|
|
103
|
+
if db_handler is not None and not isinstance(db_handler, BaseDB):
|
|
104
|
+
raise TypeError(
|
|
105
|
+
f"db_handler must be a BaseDB instance, got {type(db_handler)}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self._storage = storage
|
|
109
|
+
self._db = db_handler
|
|
110
|
+
self._factory = factory
|
|
111
|
+
self._logger = logger or DEFAULT_LOGGER
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# factory property (DIP: allows late injection / substitution)
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def factory(self) -> CertificateFactory | None:
|
|
119
|
+
"""
|
|
120
|
+
The active ``CertificateFactory`` used for certificate issuance.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
CertificateFactory | None
|
|
125
|
+
The current factory, or ``None`` if not yet initialised.
|
|
126
|
+
"""
|
|
127
|
+
return self._factory
|
|
128
|
+
|
|
129
|
+
@factory.setter
|
|
130
|
+
def factory(self, value: CertificateFactory) -> None:
|
|
131
|
+
"""
|
|
132
|
+
Replace the active ``CertificateFactory``.
|
|
133
|
+
|
|
134
|
+
Useful for rotating to a new CA without recreating the entire manager.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
value : CertificateFactory
|
|
139
|
+
New factory instance to use for subsequent issuance operations.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
TypeError
|
|
144
|
+
If *value* is not a ``CertificateFactory`` instance.
|
|
145
|
+
"""
|
|
146
|
+
if not isinstance(value, CertificateFactory):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
f"factory must be a CertificateFactory instance, got {type(value)}"
|
|
149
|
+
)
|
|
150
|
+
self._logger.debug("CertificateFactory replaced: %s", value)
|
|
151
|
+
self._factory = value
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# CA bootstrap
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def create_self_signed_ca(
|
|
158
|
+
self,
|
|
159
|
+
config: CAConfig,
|
|
160
|
+
cert_path: str | None = None,
|
|
161
|
+
uuid_str: str | None = None,
|
|
162
|
+
is_overwrite: bool = False,
|
|
163
|
+
) -> tuple[Path, Path]:
|
|
164
|
+
"""
|
|
165
|
+
Generate a self-signed root CA certificate and persist both artefacts.
|
|
166
|
+
|
|
167
|
+
Delegates cryptographic generation to
|
|
168
|
+
``CertificateFactory.build_self_signed_ca`` and then saves the
|
|
169
|
+
certificate and private key through the configured ``BaseStorage``.
|
|
170
|
+
When a ``db_handler`` is present the certificate is also registered in
|
|
171
|
+
the database.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
config : CAConfig
|
|
176
|
+
Pydantic model carrying ``common_name``, ``organization``,
|
|
177
|
+
``country``, ``key_size``, and ``days_valid``.
|
|
178
|
+
cert_path : str | None
|
|
179
|
+
Sub-directory under the storage base folder. ``None`` places the
|
|
180
|
+
files directly in the base folder.
|
|
181
|
+
uuid_str : str | None
|
|
182
|
+
Explicit UUID for the storage sub-directory. ``None`` causes the
|
|
183
|
+
storage layer to generate one automatically.
|
|
184
|
+
is_overwrite : bool
|
|
185
|
+
When ``True``, any existing certificate with the same CN is revoked
|
|
186
|
+
and its files are deleted before the new one is saved. When
|
|
187
|
+
``False`` (default), ``NotUniqueCertOwner`` is raised if a conflict
|
|
188
|
+
exists.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
tuple[str, str]
|
|
193
|
+
``(path_to_cert_pem, path_to_key_pem)`` as returned by the storage
|
|
194
|
+
layer.
|
|
195
|
+
"""
|
|
196
|
+
self._logger.info(
|
|
197
|
+
"Creating self-signed CA: CN=%s, overwrite=%s",
|
|
198
|
+
config.common_name,
|
|
199
|
+
is_overwrite,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
certificate, private_key = CertificateFactory.build_self_signed_ca(
|
|
203
|
+
**config.model_dump(), logger=self._logger
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
path_to_cert, uuid_str = self._storage.save_certificate(
|
|
207
|
+
cert=certificate,
|
|
208
|
+
cert_path=cert_path,
|
|
209
|
+
file_name="ca",
|
|
210
|
+
uuid_str=uuid_str,
|
|
211
|
+
is_overwrite=is_overwrite,
|
|
212
|
+
)
|
|
213
|
+
path_to_key, uuid_str = self._storage.save_certificate(
|
|
214
|
+
cert=private_key,
|
|
215
|
+
cert_path=cert_path,
|
|
216
|
+
file_name="ca",
|
|
217
|
+
uuid_str=uuid_str,
|
|
218
|
+
is_overwrite=is_overwrite,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if self._db:
|
|
222
|
+
self._persist_cert_to_db(
|
|
223
|
+
common_name=config.common_name,
|
|
224
|
+
uuid_str=uuid_str,
|
|
225
|
+
certificate=certificate,
|
|
226
|
+
cert_type=CertType.CA,
|
|
227
|
+
cert_path=cert_path,
|
|
228
|
+
is_overwrite=is_overwrite,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
self._logger.info("CA saved: cert=%s, key=%s", path_to_cert, path_to_key)
|
|
232
|
+
return path_to_cert, path_to_key
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# Certificate issuance
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def issue_certificate(
|
|
239
|
+
self,
|
|
240
|
+
config: ClientConfig,
|
|
241
|
+
cert_path: str | None = None,
|
|
242
|
+
uuid_str: str | None = None,
|
|
243
|
+
is_overwrite: bool = False,
|
|
244
|
+
) -> tuple[x509.Certificate, object, x509.CertificateSigningRequest]:
|
|
245
|
+
"""
|
|
246
|
+
Issue a new end-entity certificate and persist all three artefacts.
|
|
247
|
+
|
|
248
|
+
The method generates the certificate, private key, and CSR via the
|
|
249
|
+
configured ``CertificateFactory``, then saves each file through
|
|
250
|
+
``BaseStorage``. If a ``db_handler`` is configured the certificate
|
|
251
|
+
metadata is also written to the database.
|
|
252
|
+
|
|
253
|
+
Artefacts written to storage
|
|
254
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
255
|
+
- ``<file_name>.pem`` — the signed certificate.
|
|
256
|
+
- ``<file_name>.key`` — the private key (unencrypted by default).
|
|
257
|
+
- ``<file_name>.csr`` — the certificate signing request (for audit).
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
config : ClientConfig
|
|
262
|
+
Pydantic model containing all certificate parameters: ``common_name``,
|
|
263
|
+
``serial_type``, ``key_size``, ``days_valid``, ``email``,
|
|
264
|
+
``is_server_cert``, ``is_client_cert``, ``san_dns``, ``san_ip``,
|
|
265
|
+
and an optional ``name`` for the output file basename.
|
|
266
|
+
cert_path : str | None
|
|
267
|
+
Sub-directory under the storage base folder.
|
|
268
|
+
uuid_str : str | None
|
|
269
|
+
Explicit UUID; ``None`` auto-generates one.
|
|
270
|
+
is_overwrite : bool
|
|
271
|
+
Allow replacing an existing certificate with the same CN.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
tuple[x509.Certificate, rsa.RSAPrivateKey, x509.CertificateSigningRequest]
|
|
276
|
+
``(certificate, private_key, csr)`` in-memory objects.
|
|
277
|
+
|
|
278
|
+
Raises
|
|
279
|
+
------
|
|
280
|
+
ValueError
|
|
281
|
+
If ``self.factory`` has not been initialised.
|
|
282
|
+
NotUniqueCertOwner
|
|
283
|
+
If *is_overwrite* is ``False`` and a certificate with the same CN
|
|
284
|
+
already exists in the database.
|
|
285
|
+
"""
|
|
286
|
+
if self._factory is None:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"factory is not initialised. "
|
|
289
|
+
"Set CertLifecycleManager.factory before issuing certificates."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
file_name = self._derive_file_name(config)
|
|
293
|
+
self._logger.info(
|
|
294
|
+
"Issuing certificate: CN=%s, file=%s, overwrite=%s",
|
|
295
|
+
config.common_name,
|
|
296
|
+
file_name,
|
|
297
|
+
is_overwrite,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
certificate, private_key, csr = self._factory.issue_certificate(
|
|
301
|
+
**config.model_dump(exclude={"name"})
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
path_to_cert, uuid_str = self._storage.save_certificate(
|
|
305
|
+
cert=certificate,
|
|
306
|
+
cert_path=cert_path,
|
|
307
|
+
file_name=file_name,
|
|
308
|
+
uuid_str=uuid_str,
|
|
309
|
+
is_overwrite=is_overwrite,
|
|
310
|
+
)
|
|
311
|
+
path_to_key, uuid_str = self._storage.save_certificate(
|
|
312
|
+
cert=private_key,
|
|
313
|
+
cert_path=cert_path,
|
|
314
|
+
file_name=file_name,
|
|
315
|
+
uuid_str=uuid_str,
|
|
316
|
+
is_overwrite=is_overwrite,
|
|
317
|
+
)
|
|
318
|
+
path_to_csr, uuid_str = self._storage.save_certificate(
|
|
319
|
+
cert=csr,
|
|
320
|
+
cert_path=cert_path,
|
|
321
|
+
file_name=file_name,
|
|
322
|
+
uuid_str=uuid_str,
|
|
323
|
+
is_overwrite=is_overwrite,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if self._db:
|
|
327
|
+
self._persist_cert_to_db(
|
|
328
|
+
common_name=config.common_name,
|
|
329
|
+
uuid_str=uuid_str,
|
|
330
|
+
certificate=certificate,
|
|
331
|
+
cert_type=config.serial_type,
|
|
332
|
+
cert_path=cert_path,
|
|
333
|
+
is_overwrite=is_overwrite,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
self._logger.info(
|
|
337
|
+
"Certificate artefacts saved: cert=%s, key=%s, csr=%s",
|
|
338
|
+
path_to_cert,
|
|
339
|
+
path_to_key,
|
|
340
|
+
path_to_csr,
|
|
341
|
+
)
|
|
342
|
+
return certificate, private_key, csr
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# Revocation
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def revoke_certificate(
|
|
349
|
+
self,
|
|
350
|
+
serial: int,
|
|
351
|
+
reason: x509.ReasonFlags,
|
|
352
|
+
) -> bool:
|
|
353
|
+
"""
|
|
354
|
+
Revoke the certificate identified by *serial*.
|
|
355
|
+
|
|
356
|
+
Delegates to ``BaseDB.revoke_certificate``. The record is updated
|
|
357
|
+
in-place (status → REVOKED, revocation date and reason stored); no
|
|
358
|
+
file is deleted. Call ``generate_crl`` afterwards to publish the
|
|
359
|
+
updated revocation list.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
serial : int
|
|
364
|
+
Integer serial number of the certificate to revoke.
|
|
365
|
+
reason : x509.ReasonFlags
|
|
366
|
+
RFC 5280 revocation reason code (e.g.
|
|
367
|
+
``x509.ReasonFlags.key_compromise``).
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
bool
|
|
372
|
+
``True`` if the revocation was recorded successfully;
|
|
373
|
+
``False`` if the operation failed (see logs for details).
|
|
374
|
+
|
|
375
|
+
Raises
|
|
376
|
+
------
|
|
377
|
+
DBNotInitedError
|
|
378
|
+
If no ``db_handler`` was provided at construction time.
|
|
379
|
+
"""
|
|
380
|
+
self._require_db()
|
|
381
|
+
self._logger.info("Revoking certificate: serial=%d, reason=%s", serial, reason)
|
|
382
|
+
|
|
383
|
+
is_success, status = self._db.revoke_certificate(
|
|
384
|
+
serial_number=serial, reason=reason
|
|
385
|
+
)
|
|
386
|
+
if not is_success:
|
|
387
|
+
self._logger.warning(
|
|
388
|
+
"Failed to revoke certificate serial=%d: %s", serial, status
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
self._logger.info("Certificate serial=%d revoked successfully", serial)
|
|
392
|
+
|
|
393
|
+
return is_success
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
# CRL generation
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def generate_crl(self, days_valid: int = 1) -> x509.CertificateRevocationList:
|
|
400
|
+
"""
|
|
401
|
+
Build, sign, and persist a fresh Certificate Revocation List.
|
|
402
|
+
|
|
403
|
+
Retrieves all currently-revoked certificates from the database,
|
|
404
|
+
passes them to ``CertificateFactory.build_crl``, and writes the
|
|
405
|
+
resulting CRL to storage as ``crl.pem`` (overwriting any previous
|
|
406
|
+
version — CRLs are always regenerated in-place).
|
|
407
|
+
|
|
408
|
+
Parameters
|
|
409
|
+
----------
|
|
410
|
+
days_valid : int
|
|
411
|
+
Number of days until the CRL's ``nextUpdate`` field. Relying
|
|
412
|
+
parties will reject the CRL after this point. Default: ``1``.
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
x509.CertificateRevocationList
|
|
417
|
+
The signed CRL object (also persisted to storage).
|
|
418
|
+
|
|
419
|
+
Raises
|
|
420
|
+
------
|
|
421
|
+
DBNotInitedError
|
|
422
|
+
If no ``db_handler`` was provided.
|
|
423
|
+
ValueError
|
|
424
|
+
If ``self.factory`` has not been initialised.
|
|
425
|
+
"""
|
|
426
|
+
self._require_db()
|
|
427
|
+
self._require_factory()
|
|
428
|
+
|
|
429
|
+
self._logger.info("Generating CRL: days_valid=%d", days_valid)
|
|
430
|
+
revoked_certs = self._db.get_revoked_certificates()
|
|
431
|
+
|
|
432
|
+
crl = self._factory.build_crl(
|
|
433
|
+
revoked_certs=revoked_certs, days_valid=days_valid
|
|
434
|
+
)
|
|
435
|
+
path, _ = self._storage.save_certificate(
|
|
436
|
+
cert=crl,
|
|
437
|
+
file_name="crl",
|
|
438
|
+
is_overwrite=True,
|
|
439
|
+
is_add_uuid=False,
|
|
440
|
+
)
|
|
441
|
+
self._logger.info("CRL saved to %s", path)
|
|
442
|
+
return crl
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
# Status and verification
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def get_certificate_status(self, serial: int) -> CertificateStatus:
|
|
449
|
+
"""
|
|
450
|
+
Determine the current status of the certificate identified by *serial*.
|
|
451
|
+
|
|
452
|
+
Looks up the certificate record in the database and evaluates its
|
|
453
|
+
state in the following priority order:
|
|
454
|
+
1. Not found → ``UNKNOWN``
|
|
455
|
+
2. Revocation date is set → ``REVOKED``
|
|
456
|
+
3. ``not_valid_after`` is in the past → ``EXPIRED``
|
|
457
|
+
4. Otherwise → ``VALID``
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
serial : int
|
|
462
|
+
Integer serial number of the certificate to check.
|
|
463
|
+
|
|
464
|
+
Returns
|
|
465
|
+
-------
|
|
466
|
+
CertificateStatus
|
|
467
|
+
One of ``VALID``, ``REVOKED``, ``EXPIRED``, or ``UNKNOWN``.
|
|
468
|
+
|
|
469
|
+
Raises
|
|
470
|
+
------
|
|
471
|
+
DBNotInitedError
|
|
472
|
+
If no ``db_handler`` was provided.
|
|
473
|
+
"""
|
|
474
|
+
self._require_db()
|
|
475
|
+
|
|
476
|
+
cert = self._db.get_by_serial(serial=serial)
|
|
477
|
+
if not cert:
|
|
478
|
+
self._logger.debug(
|
|
479
|
+
"Certificate serial=%d not found in DB → UNKNOWN", serial
|
|
480
|
+
)
|
|
481
|
+
return CertificateStatus.UNKNOWN
|
|
482
|
+
|
|
483
|
+
if cert.revocation_date:
|
|
484
|
+
self._logger.debug("Certificate serial=%d is revoked", serial)
|
|
485
|
+
return CertificateStatus.REVOKED
|
|
486
|
+
|
|
487
|
+
now = datetime.now(UTC)
|
|
488
|
+
if cert.not_valid_after < now:
|
|
489
|
+
self._logger.debug("Certificate serial=%d has expired", serial)
|
|
490
|
+
return CertificateStatus.EXPIRED
|
|
491
|
+
|
|
492
|
+
return CertificateStatus.VALID
|
|
493
|
+
|
|
494
|
+
def verify_certificate(self, cert: x509.Certificate) -> bool:
|
|
495
|
+
"""
|
|
496
|
+
Perform a full verification of *cert*: chain, signature, and revocation.
|
|
497
|
+
|
|
498
|
+
Combines cryptographic validation (via ``CertificateFactory.validate_cert``)
|
|
499
|
+
with a database revocation check (via ``get_certificate_status``).
|
|
500
|
+
|
|
501
|
+
Validation steps
|
|
502
|
+
~~~~~~~~~~~~~~~~
|
|
503
|
+
1. Issuer field matches the CA subject.
|
|
504
|
+
2. Current UTC time is within the validity window.
|
|
505
|
+
3. Cryptographic signature is valid.
|
|
506
|
+
4. Certificate is not listed as revoked in the database.
|
|
507
|
+
|
|
508
|
+
Parameters
|
|
509
|
+
----------
|
|
510
|
+
cert : x509.Certificate
|
|
511
|
+
The certificate object to verify.
|
|
512
|
+
|
|
513
|
+
Returns
|
|
514
|
+
-------
|
|
515
|
+
bool
|
|
516
|
+
``True`` when all checks pass.
|
|
517
|
+
|
|
518
|
+
Raises
|
|
519
|
+
------
|
|
520
|
+
ValueError
|
|
521
|
+
If ``self.factory`` has not been initialised.
|
|
522
|
+
ValidationCertError
|
|
523
|
+
If the certificate fails any cryptographic check or is revoked.
|
|
524
|
+
"""
|
|
525
|
+
self._require_factory()
|
|
526
|
+
|
|
527
|
+
self._logger.info("Verifying certificate serial=%s", cert.serial_number)
|
|
528
|
+
self._factory.validate_cert(cert=cert)
|
|
529
|
+
|
|
530
|
+
status = self.get_certificate_status(cert.serial_number)
|
|
531
|
+
if status == CertificateStatus.REVOKED:
|
|
532
|
+
self._logger.warning(
|
|
533
|
+
"Verification failed: certificate serial=%s is revoked",
|
|
534
|
+
cert.serial_number,
|
|
535
|
+
)
|
|
536
|
+
raise ValidationCertError("Certificate is revoked")
|
|
537
|
+
|
|
538
|
+
self._logger.info(
|
|
539
|
+
"Certificate serial=%s verified successfully", cert.serial_number
|
|
540
|
+
)
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
# ------------------------------------------------------------------
|
|
544
|
+
# Rotation
|
|
545
|
+
# ------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
def rotate_certificate(
|
|
548
|
+
self,
|
|
549
|
+
serial: int,
|
|
550
|
+
config: ClientConfig,
|
|
551
|
+
) -> tuple[x509.Certificate, object, x509.CertificateSigningRequest]:
|
|
552
|
+
"""
|
|
553
|
+
Revoke an existing certificate and issue a replacement in a single operation.
|
|
554
|
+
|
|
555
|
+
The old certificate is revoked with reason ``superseded`` before the
|
|
556
|
+
new one is issued. Both operations are performed against the configured
|
|
557
|
+
``db_handler``; if the revocation fails an exception is propagated and
|
|
558
|
+
the new certificate is not issued.
|
|
559
|
+
|
|
560
|
+
Parameters
|
|
561
|
+
----------
|
|
562
|
+
serial : int
|
|
563
|
+
Serial number of the certificate to replace.
|
|
564
|
+
config : ClientConfig
|
|
565
|
+
Parameters for the replacement certificate. The CN may differ
|
|
566
|
+
from the original.
|
|
567
|
+
|
|
568
|
+
Returns
|
|
569
|
+
-------
|
|
570
|
+
tuple[x509.Certificate, rsa.RSAPrivateKey, x509.CertificateSigningRequest]
|
|
571
|
+
``(new_certificate, new_private_key, new_csr)``.
|
|
572
|
+
|
|
573
|
+
Raises
|
|
574
|
+
------
|
|
575
|
+
DBNotInitedError
|
|
576
|
+
If no ``db_handler`` was provided.
|
|
577
|
+
CertNotFound
|
|
578
|
+
If no certificate with *serial* exists in the database.
|
|
579
|
+
ValueError
|
|
580
|
+
If ``self.factory`` has not been initialised.
|
|
581
|
+
"""
|
|
582
|
+
self._require_db()
|
|
583
|
+
|
|
584
|
+
self._logger.info(
|
|
585
|
+
"Rotating certificate: serial=%d, new_CN=%s", serial, config.common_name
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
existing = self._db.get_by_serial(serial=serial)
|
|
589
|
+
if not existing:
|
|
590
|
+
self._logger.error("Certificate serial=%d not found for rotation", serial)
|
|
591
|
+
raise CertNotFound()
|
|
592
|
+
|
|
593
|
+
self.revoke_certificate(serial=serial, reason=x509.ReasonFlags.superseded)
|
|
594
|
+
|
|
595
|
+
new_cert, new_key, new_csr = self.issue_certificate(config)
|
|
596
|
+
self._logger.info(
|
|
597
|
+
"Rotation complete: old serial=%d replaced by serial=%s",
|
|
598
|
+
serial,
|
|
599
|
+
new_cert.serial_number,
|
|
600
|
+
)
|
|
601
|
+
return new_cert, new_key, new_csr
|
|
602
|
+
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
# Private helpers
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
def _persist_cert_to_db(
|
|
608
|
+
self,
|
|
609
|
+
common_name: str,
|
|
610
|
+
uuid_str: str,
|
|
611
|
+
certificate: x509.Certificate,
|
|
612
|
+
cert_type: CertType,
|
|
613
|
+
cert_path: str | None,
|
|
614
|
+
is_overwrite: bool,
|
|
615
|
+
) -> None:
|
|
616
|
+
"""
|
|
617
|
+
Register *certificate* in the database, handling CN conflicts.
|
|
618
|
+
|
|
619
|
+
If a VALID certificate with *common_name* already exists:
|
|
620
|
+
- *is_overwrite=True* → the existing cert is revoked (``key_compromise``)
|
|
621
|
+
and its storage folder is deleted before the new record is inserted.
|
|
622
|
+
- *is_overwrite=False* → ``NotUniqueCertOwner`` is raised immediately.
|
|
623
|
+
|
|
624
|
+
Parameters
|
|
625
|
+
----------
|
|
626
|
+
common_name : str
|
|
627
|
+
The CN of the certificate being registered.
|
|
628
|
+
uuid_str : str
|
|
629
|
+
UUID of the storage folder that holds the artefact files.
|
|
630
|
+
certificate : x509.Certificate
|
|
631
|
+
The newly issued certificate object.
|
|
632
|
+
cert_type : CertType
|
|
633
|
+
Category of the certificate (CA, SERVICE, DEVICE, etc.).
|
|
634
|
+
cert_path : str | None
|
|
635
|
+
Base sub-path used by the storage layer; needed when deleting the
|
|
636
|
+
old artefact folder on overwrite.
|
|
637
|
+
is_overwrite : bool
|
|
638
|
+
Whether to replace an existing certificate with the same CN.
|
|
639
|
+
|
|
640
|
+
Raises
|
|
641
|
+
------
|
|
642
|
+
NotUniqueCertOwner
|
|
643
|
+
If *common_name* is already in use and *is_overwrite* is ``False``.
|
|
644
|
+
"""
|
|
645
|
+
existing = self._db.get_by_name(common_name=common_name)
|
|
646
|
+
|
|
647
|
+
if existing:
|
|
648
|
+
if not is_overwrite:
|
|
649
|
+
self._logger.warning(
|
|
650
|
+
"Duplicate CN detected: CN=%s is already registered", common_name
|
|
651
|
+
)
|
|
652
|
+
raise NotUniqueCertOwner(common_name)
|
|
653
|
+
|
|
654
|
+
self._logger.info(
|
|
655
|
+
"Overwriting existing certificate: CN=%s (serial=%s)",
|
|
656
|
+
common_name,
|
|
657
|
+
existing.serial_number,
|
|
658
|
+
)
|
|
659
|
+
self.revoke_certificate(
|
|
660
|
+
serial=int(existing.serial_number),
|
|
661
|
+
reason=x509.ReasonFlags.key_compromise,
|
|
662
|
+
)
|
|
663
|
+
self._storage.delete_certificate_folder(
|
|
664
|
+
uuid_str=str(existing.uuid), cert_path=cert_path
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
self._db.register_cert_in_db(
|
|
668
|
+
cert=certificate,
|
|
669
|
+
uuid=uuid_str,
|
|
670
|
+
key_type=cert_type,
|
|
671
|
+
)
|
|
672
|
+
self._logger.debug(
|
|
673
|
+
"Certificate registered in DB: CN=%s, uuid=%s", common_name, uuid_str
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _derive_file_name(config: ClientConfig) -> str:
|
|
678
|
+
"""
|
|
679
|
+
Derive the base filename for the certificate artefacts from *config*.
|
|
680
|
+
|
|
681
|
+
Priority: ``config.name`` (explicit name) → ``config.common_name``
|
|
682
|
+
lowercased with OS path separators replaced by underscores to prevent
|
|
683
|
+
accidental directory traversal.
|
|
684
|
+
|
|
685
|
+
Parameters
|
|
686
|
+
----------
|
|
687
|
+
config : ClientConfig
|
|
688
|
+
Certificate configuration model.
|
|
689
|
+
|
|
690
|
+
Returns
|
|
691
|
+
-------
|
|
692
|
+
str
|
|
693
|
+
Sanitised filename without extension (e.g. ``"my-service"``).
|
|
694
|
+
"""
|
|
695
|
+
return config.name or config.common_name.lower().replace(os.sep, "_")
|
|
696
|
+
|
|
697
|
+
def _require_db(self) -> None:
|
|
698
|
+
"""
|
|
699
|
+
Assert that a database handler is configured.
|
|
700
|
+
|
|
701
|
+
Raises
|
|
702
|
+
------
|
|
703
|
+
DBNotInitedError
|
|
704
|
+
If ``self._db`` is ``None``.
|
|
705
|
+
"""
|
|
706
|
+
if not self._db:
|
|
707
|
+
raise DBNotInitedError()
|
|
708
|
+
|
|
709
|
+
def _require_factory(self) -> None:
|
|
710
|
+
"""
|
|
711
|
+
Assert that a certificate factory is configured.
|
|
712
|
+
|
|
713
|
+
Raises
|
|
714
|
+
------
|
|
715
|
+
ValueError
|
|
716
|
+
If ``self._factory`` is ``None``.
|
|
717
|
+
"""
|
|
718
|
+
if not self._factory:
|
|
719
|
+
raise ValueError(
|
|
720
|
+
"factory is not initialised. "
|
|
721
|
+
"Set CertLifecycleManager.factory before using this operation."
|
|
722
|
+
)
|