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,415 @@
1
+ """
2
+ Synchronous SQLAlchemy-backed implementation of the certificate registry.
3
+
4
+ Module-level contents
5
+ ---------------------
6
+ ``DatabaseManager`` — owns the SQLAlchemy engine and session factory.
7
+ No domain logic; connection lifecycle only.
8
+ ``SyncDBHandler`` — implements ``BaseDB``; all certificate registry
9
+ operations with explicit, atomic transaction management.
10
+
11
+ SOLID notes
12
+ -----------
13
+ SRP : ``DatabaseManager`` handles engine creation and session provisioning.
14
+ ``SyncDBHandler`` handles domain operations on ``CertificateRecord`` rows.
15
+ Neither class knows about the other's internal details.
16
+ OCP : Additional query methods are added to ``SyncDBHandler`` without touching
17
+ ``DatabaseManager`` or ``BaseDB``.
18
+ LSP : ``SyncDBHandler`` satisfies the full ``BaseDB`` contract and is
19
+ transparently substitutable in any consumer that depends on ``BaseDB``.
20
+ ISP : ``BaseDB`` is declared in ``db/base_db.py``; this module imports and
21
+ extends it without adding unrelated methods visible to callers.
22
+ DIP : ``CertLifecycleManager`` depends on ``BaseDB``, never on
23
+ ``SyncDBHandler`` directly.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Generator
29
+ import datetime
30
+ from logging import Logger
31
+
32
+ from cryptography import x509
33
+ from cryptography.hazmat._oid import NameOID
34
+ from cryptography.hazmat.primitives import serialization
35
+ from sqlalchemy import create_engine, delete, select
36
+ from sqlalchemy.orm import Session, sessionmaker
37
+
38
+ from tiny_ca.const import CertType
39
+ from tiny_ca.settings import DEFAULT_LOGGER
40
+
41
+ from .base_db import BaseDB
42
+ from .const import CertificateStatus, RevokeStatus
43
+ from .models import Base, CertificateRecord
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SRP: engine and session-factory management
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class DatabaseManager:
51
+ """
52
+ Manages the SQLAlchemy engine and ``sessionmaker`` factory.
53
+
54
+ Responsibility: connection lifecycle *only*. This class creates the
55
+ engine, optionally initialises the schema, and hands out new ``Session``
56
+ objects on request. It performs no queries and contains no domain logic.
57
+
58
+ Parameters
59
+ ----------
60
+ db_url : str
61
+ SQLAlchemy database URL. Supports any SQLAlchemy-compatible backend,
62
+ e.g. ``"sqlite:///ca.db"`` or
63
+ ``"postgresql+psycopg2://user:pass@host/dbname"``.
64
+ Default: ``"sqlite:///ca_repository.db"``.
65
+ create_all : bool
66
+ When ``True`` (default), calls ``Base.metadata.create_all`` at
67
+ construction so the schema is present before the first query.
68
+ Set to ``False`` when using Alembic or another migration tool that
69
+ manages the schema independently.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ db_url: str = "sqlite:///ca_repository.db",
75
+ create_all: bool = True,
76
+ ) -> None:
77
+ self._engine = create_engine(db_url)
78
+ if create_all:
79
+ Base.metadata.create_all(self._engine)
80
+ self._Session: sessionmaker[Session] = sessionmaker(bind=self._engine)
81
+
82
+ def session(self) -> Session:
83
+ """
84
+ Provision a new SQLAlchemy ``Session`` bound to the managed engine.
85
+
86
+ The caller is fully responsible for the session lifecycle: committing
87
+ successful transactions, rolling back on errors, and closing the
88
+ session in a ``finally`` block. Example::
89
+
90
+ session = db_manager.session()
91
+ try:
92
+ session.add(record)
93
+ session.commit()
94
+ except Exception:
95
+ session.rollback()
96
+ raise
97
+ finally:
98
+ session.close()
99
+
100
+ Returns
101
+ -------
102
+ Session
103
+ A new, uncommitted SQLAlchemy ORM session.
104
+ """
105
+ return self._Session()
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # SRP: domain operations on CertificateRecord rows
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ class SyncDBHandler(BaseDB):
114
+ """
115
+ Synchronous, SQLAlchemy-backed certificate registry.
116
+
117
+ Implements the full ``BaseDB`` contract with explicit, atomic transaction
118
+ management. Every public method follows the same pattern:
119
+
120
+ 1. Open a new session via ``self._db.session()``.
121
+ 2. Execute the query / mutation inside a ``try`` block.
122
+ 3. Commit on success or roll back on any exception.
123
+ 4. Always close the session in the ``finally`` block.
124
+
125
+ This guarantees that no session is leaked regardless of outcome, and that
126
+ partial writes are never visible to other readers.
127
+
128
+ Parameters
129
+ ----------
130
+ db_url : str
131
+ SQLAlchemy database URL forwarded to ``DatabaseManager``.
132
+ logger : Logger | None
133
+ Logger for operational and diagnostic messages.
134
+ Falls back to ``DEFAULT_LOGGER`` when ``None``.
135
+ """
136
+
137
+ def __init__(self, db_url: str, logger: Logger | None = None) -> None:
138
+ self._logger = logger or DEFAULT_LOGGER
139
+ self._db = DatabaseManager(db_url=db_url)
140
+
141
+ # ------------------------------------------------------------------
142
+ # BaseDB interface
143
+ # ------------------------------------------------------------------
144
+
145
+ def get_by_serial(self, serial: int) -> CertificateRecord | None:
146
+ """
147
+ Fetch a single certificate record by its X.509 serial number.
148
+
149
+ The serial is stored as a string in the database (to avoid integer
150
+ overflow across all backends); the conversion is handled internally.
151
+
152
+ Parameters
153
+ ----------
154
+ serial : int
155
+ Integer serial number to look up.
156
+
157
+ Returns
158
+ -------
159
+ CertificateRecord | None
160
+ The matching ORM record, or ``None`` if no record exists for
161
+ *serial* or if a database error occurs.
162
+ """
163
+ session = self._db.session()
164
+ try:
165
+ stmt = select(CertificateRecord).where(
166
+ CertificateRecord.serial_number == str(serial)
167
+ )
168
+ cert: CertificateRecord | None = session.execute(stmt).scalar_one_or_none()
169
+ self._logger.debug("get_by_serial(%d) → %s", serial, cert)
170
+ return cert
171
+ except Exception as exc:
172
+ self._logger.error(
173
+ "get_by_serial(%d) failed: %s", serial, exc, exc_info=True
174
+ )
175
+ return None
176
+ finally:
177
+ session.close()
178
+
179
+ def get_by_name(self, common_name: str) -> CertificateRecord | None:
180
+ """
181
+ Fetch the active VALID certificate record for the given Common Name.
182
+
183
+ Only records with ``status == CertificateStatus.VALID`` are returned.
184
+ Revoked and expired records are ignored so that the caller always
185
+ receives the currently-active certificate for a given CN, or ``None``
186
+ if no active certificate exists.
187
+
188
+ Parameters
189
+ ----------
190
+ common_name : str
191
+ The CN (Common Name) value from the certificate Subject field.
192
+
193
+ Returns
194
+ -------
195
+ CertificateRecord | None
196
+ The matching VALID record, or ``None`` if absent or on DB error.
197
+ """
198
+ session = self._db.session()
199
+ try:
200
+ stmt = select(CertificateRecord).where(
201
+ CertificateRecord.common_name == common_name,
202
+ CertificateRecord.status == CertificateStatus.VALID,
203
+ )
204
+ cert: CertificateRecord | None = session.execute(stmt).scalar_one_or_none()
205
+ self._logger.debug("get_by_name(%r) → %s", common_name, cert)
206
+ return cert
207
+ except Exception as exc:
208
+ self._logger.error(
209
+ "get_by_name(%r) failed: %s", common_name, exc, exc_info=True
210
+ )
211
+ return None
212
+ finally:
213
+ session.close()
214
+
215
+ def register_cert_in_db(
216
+ self,
217
+ cert: x509.Certificate,
218
+ uuid: str,
219
+ key_type: CertType = CertType.DEVICE,
220
+ ) -> bool:
221
+ """
222
+ Persist a newly issued certificate to the registry.
223
+
224
+ Creates a new ``CertificateRecord`` row with ``status=VALID`` from the
225
+ metadata and PEM encoding of *cert*. The full PEM is stored so the
226
+ certificate can be reconstructed independently of the filesystem.
227
+
228
+ Parameters
229
+ ----------
230
+ cert : x509.Certificate
231
+ The issued X.509 certificate. Its Subject must contain at least
232
+ one ``commonName`` (CN) attribute.
233
+ uuid : str
234
+ UUID string that identifies the storage folder holding the
235
+ corresponding ``.pem``, ``.key``, and ``.csr`` files.
236
+ key_type : CertType
237
+ Certificate category. Stored as its ``str`` value (e.g.
238
+ ``"device"``). Default: ``CertType.DEVICE``.
239
+
240
+ Returns
241
+ -------
242
+ bool
243
+ ``True`` if the record was committed successfully;
244
+ ``False`` if the operation was rolled back due to an error
245
+ (e.g. duplicate serial, constraint violation).
246
+
247
+ Raises
248
+ ------
249
+ IndexError
250
+ Re-raised if the certificate contains no CN attribute, indicating
251
+ a malformed certificate that should not be stored.
252
+ """
253
+ session = self._db.session()
254
+ try:
255
+ common_name = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[
256
+ 0
257
+ ].value
258
+ common_name = (
259
+ common_name
260
+ if isinstance(common_name, str)
261
+ else common_name.decode("utf-8")
262
+ )
263
+
264
+ record = CertificateRecord(
265
+ serial_number=str(cert.serial_number),
266
+ common_name=common_name,
267
+ not_valid_before=cert.not_valid_before_utc,
268
+ not_valid_after=cert.not_valid_after_utc,
269
+ certificate_pem=cert.public_bytes(serialization.Encoding.PEM).decode(
270
+ "utf-8"
271
+ ),
272
+ status=CertificateStatus.VALID,
273
+ key_type=key_type.value,
274
+ uuid=uuid,
275
+ )
276
+ session.add(record)
277
+ session.commit()
278
+ self._logger.info(
279
+ "Certificate registered: CN=%r, serial=%s, uuid=%s",
280
+ common_name,
281
+ cert.serial_number,
282
+ uuid,
283
+ )
284
+ return True
285
+ except Exception as exc:
286
+ session.rollback()
287
+ self._logger.error(
288
+ "register_cert_in_db failed (serial=%s): %s",
289
+ cert.serial_number,
290
+ exc,
291
+ exc_info=True,
292
+ )
293
+ return False
294
+ finally:
295
+ session.close()
296
+
297
+ def revoke_certificate(
298
+ self,
299
+ serial_number: int,
300
+ reason: x509.ReasonFlags = x509.ReasonFlags.unspecified,
301
+ ) -> tuple[bool, RevokeStatus]:
302
+ """
303
+ Mark a certificate as revoked and record the reason and timestamp.
304
+
305
+ Looks up the certificate by *serial_number* filtered to
306
+ ``status == VALID`` — already-revoked or unknown serials are treated
307
+ as not found. On success the record is updated in-place:
308
+ - ``status`` → ``CertificateStatus.REVOKED``
309
+ - ``revocation_reason`` → integer value of *reason*
310
+ - ``revocation_date`` → current UTC timestamp
311
+
312
+ The change is committed atomically; a rollback is performed on any
313
+ unexpected error.
314
+
315
+ Parameters
316
+ ----------
317
+ serial_number : int
318
+ Serial number of the certificate to revoke.
319
+ reason : x509.ReasonFlags
320
+ RFC 5280 §5.3.1 revocation reason code.
321
+ Default: ``x509.ReasonFlags.unspecified`` (code 0).
322
+
323
+ Returns
324
+ -------
325
+ tuple[bool, RevokeStatus]
326
+ ``(True, RevokeStatus.OK)`` — revocation committed.
327
+ ``(False, RevokeStatus.NOT_FOUND)`` — no VALID cert with that serial.
328
+ ``(False, RevokeStatus.UNKNOWN_ERROR)`` — unexpected internal error.
329
+ """
330
+ session = self._db.session()
331
+ try:
332
+ stmt = select(CertificateRecord).where(
333
+ CertificateRecord.serial_number == str(serial_number),
334
+ CertificateRecord.status == CertificateStatus.VALID,
335
+ )
336
+ cert: CertificateRecord | None = session.execute(stmt).scalar_one_or_none()
337
+
338
+ if cert is None:
339
+ self._logger.warning(
340
+ "revoke_certificate: no VALID record found for serial=%d",
341
+ serial_number,
342
+ )
343
+ return False, RevokeStatus.NOT_FOUND
344
+
345
+ cert.status = CertificateStatus.REVOKED
346
+ cert.revocation_reason = reason.value if hasattr(reason, "value") else 0 # type: ignore
347
+ cert.revocation_date = datetime.datetime.now(datetime.UTC) # type: ignore
348
+
349
+ session.commit()
350
+ self._logger.info(
351
+ "Certificate revoked: serial=%d, reason=%s", serial_number, reason
352
+ )
353
+ return True, RevokeStatus.OK
354
+
355
+ except Exception as exc:
356
+ session.rollback()
357
+ self._logger.error(
358
+ "revoke_certificate(%d) failed: %s", serial_number, exc, exc_info=True
359
+ )
360
+ return False, RevokeStatus.UNKNOWN_ERROR
361
+ finally:
362
+ session.close()
363
+
364
+ def get_revoked_certificates(self) -> Generator[CertificateRecord, None, None]:
365
+ """
366
+ Yield revoked certificate rows relevant for the current CRL window.
367
+
368
+ A record is included when **all** of the following conditions hold:
369
+
370
+ 1. ``revocation_date`` is not ``NULL`` — the certificate was actually revoked.
371
+ 2. ``not_valid_after > now`` — the certificate has not yet expired; expired
372
+ certificates need not appear in a CRL because relying parties will
373
+ reject them regardless.
374
+ 3. ``revocation_date > now - 365 days`` — the revocation is recent enough
375
+ to be relevant. This prevents unbounded CRL growth from very old
376
+ entries that no relying party could still encounter.
377
+
378
+ Only three columns are selected (``serial_number``, ``revocation_date``,
379
+ ``revocation_reason``) to minimise data transfer; callers must not
380
+ access other ``CertificateRecord`` attributes on the yielded rows.
381
+
382
+ Yields
383
+ ------
384
+ CertificateRecord
385
+ SQLAlchemy ``Row`` objects with ``serial_number``,
386
+ ``revocation_date``, and ``revocation_reason`` attributes.
387
+
388
+ Notes
389
+ -----
390
+ All rows are fetched in a single query before yielding begins. The
391
+ session is closed in the ``finally`` block; do not use the yielded
392
+ rows after the generator has been exhausted or abandoned.
393
+ """
394
+ now = datetime.datetime.now(datetime.UTC)
395
+ cutoff = now - datetime.timedelta(days=365)
396
+
397
+ session = self._db.session()
398
+ try:
399
+ stmt = select(
400
+ CertificateRecord.serial_number,
401
+ CertificateRecord.revocation_date,
402
+ CertificateRecord.revocation_reason,
403
+ ).where(
404
+ CertificateRecord.revocation_date.isnot(None),
405
+ CertificateRecord.not_valid_after > now,
406
+ CertificateRecord.revocation_date > cutoff,
407
+ )
408
+ rows = session.execute(stmt)
409
+ yield from rows # type: ignore
410
+ except Exception as exc:
411
+ self._logger.error(
412
+ "get_revoked_certificates failed: %s", exc, exc_info=True
413
+ )
414
+ finally:
415
+ session.close()
tiny_ca/exc.py ADDED
@@ -0,0 +1,103 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ from tiny_ca.settings import DT_STR_FORMAT
5
+
6
+
7
+ class CryptoException(Exception):
8
+ """Raised when certificate validation fails."""
9
+
10
+ message: str
11
+
12
+ def __init__(self, message: str) -> None:
13
+ super().__init__(message)
14
+
15
+ def __str__(self) -> str:
16
+ return f"Error `{self.message}`"
17
+
18
+
19
+ class NotExistCertFile(CryptoException):
20
+ def __init__(
21
+ self,
22
+ path_to_file: Path | str,
23
+ ) -> None:
24
+ message = f"Not found cert file by path `{path_to_file}`"
25
+ super().__init__(message)
26
+ self.message = message
27
+
28
+
29
+ class IsNotFile(CryptoException):
30
+ def __init__(
31
+ self,
32
+ path_to_file: Path | str,
33
+ ) -> None:
34
+ message = f"The specified path `{path_to_file}` does not contain a file "
35
+ super().__init__(message)
36
+ self.message = message
37
+
38
+
39
+ class WrongType(CryptoException):
40
+ def __init__(
41
+ self, wrong_type: str, allowed_types: tuple[str, ...] = (".key", ".pem", ".csr")
42
+ ) -> None:
43
+ message = f"The specified type `{wrong_type}` is invalid for the key. Available types: `{allowed_types}`"
44
+ super().__init__(message)
45
+ self.message = message
46
+
47
+
48
+ class ErrorLoadCert(CryptoException):
49
+ def __init__(self, path_to_file: Path | str, exc: str) -> None:
50
+ message = f"Error in time load cert {path_to_file}. Error: {exc}"
51
+ super().__init__(message)
52
+ self.message = message
53
+
54
+
55
+ class InvalidRangeTimeCertificate(CryptoException):
56
+ def __init__(self, valid_from: datetime, valid_to: datetime, now: datetime) -> None:
57
+ message = f"""The data provided for certificate generation is not valid.
58
+ The desired certificate lifetime is from: `{valid_from.strftime(DT_STR_FORMAT)}`
59
+ to: `{valid_to.strftime(DT_STR_FORMAT)}`; now: `{now.strftime(DT_STR_FORMAT)}`.
60
+ Please specify a valid lifetime.
61
+ """
62
+ super().__init__(message)
63
+ self.message = message
64
+
65
+
66
+ class FileAlreadyExists(CryptoException):
67
+ def __init__(self, path_save_cert: Path) -> None:
68
+ message = (
69
+ f"A certificate at the specified path `{path_save_cert}` already exists."
70
+ )
71
+
72
+ super().__init__(message)
73
+ self.message = message
74
+
75
+
76
+ class NotUniqueCertOwner(CryptoException):
77
+ def __init__(self, common_name: str) -> None:
78
+ message = f"Not unique common name - `{common_name}`. Change common_name or use is_overwrite=True"
79
+
80
+ super().__init__(message)
81
+ self.message = message
82
+
83
+
84
+ class DBNotInitedError(CryptoException):
85
+ def __init__(self) -> None:
86
+ message = (
87
+ "The action cannot be performed because the database is not initialized."
88
+ )
89
+ super().__init__(message)
90
+ self.message = message
91
+
92
+
93
+ class ValidationCertError(CryptoException):
94
+ def __init__(self, message: str) -> None:
95
+ super().__init__(message)
96
+ self.message = message
97
+
98
+
99
+ class CertNotFound(CryptoException):
100
+ def __init__(self) -> None:
101
+ message = "Certificate not found"
102
+ super().__init__(message)
103
+ self.message = message
@@ -0,0 +1,3 @@
1
+ from .sync_lifecycle_manager import CertLifecycleManager
2
+
3
+ __all__ = ["CertLifecycleManager"]