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.
@@ -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
+ )