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,151 @@
1
+ """
2
+ Thin wrapper around ``SerialWithEncoding`` for reading serial numbers from
3
+ existing ``x509.Certificate`` objects.
4
+
5
+ ``CertSerialParser`` does not generate serial numbers — that responsibility
6
+ belongs to ``SerialWithEncoding.generate``. Its sole purpose is to provide
7
+ a consistent, typed interface for *reading* serials back from certificates
8
+ that have already been issued.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import cast
15
+
16
+ from cryptography import x509
17
+
18
+ from tiny_ca.const import CertType
19
+ from tiny_ca.utils.serial_generator import SerialWithEncoding
20
+
21
+
22
+ class CertSerialParser:
23
+ """
24
+ Read-only accessor for X.509 certificate serial numbers.
25
+
26
+ Provides two views of the same serial number stored in an
27
+ ``x509.Certificate``:
28
+
29
+ - **raw** — the plain ``int`` exactly as stored in the certificate.
30
+ - **typed** — a ``(CertType, name_prefix)`` pair decoded by
31
+ ``SerialWithEncoding.parse``, useful when the certificate was issued
32
+ by this library and its serial number encodes type and name information.
33
+
34
+ All methods are ``@staticmethod``; no instantiation is required.
35
+ """
36
+
37
+ @staticmethod
38
+ def raw(cert: x509.Certificate) -> int:
39
+ """
40
+ Return the serial number of *cert* as a plain integer.
41
+
42
+ This is a direct read of ``cert.serial_number`` with no transformation.
43
+ Use this value when you need to pass the serial to database queries,
44
+ CRL builders, or other low-level X.509 operations.
45
+
46
+ Parameters
47
+ ----------
48
+ cert : x509.Certificate
49
+ The certificate whose serial number should be read.
50
+
51
+ Returns
52
+ -------
53
+ int
54
+ The raw integer serial number as stored in the certificate.
55
+
56
+ Examples
57
+ --------
58
+ >>> serial = CertSerialParser.raw(my_cert)
59
+ >>> record = db.get_by_serial(serial)
60
+ """
61
+ return int(cert.serial_number)
62
+
63
+ @staticmethod
64
+ def typed(cert: x509.Certificate) -> tuple[CertType | None, str]:
65
+ """
66
+ Decode the certificate's serial number into its embedded type and name.
67
+
68
+ Delegates to ``SerialWithEncoding.parse`` which reverses the bit-packing
69
+ applied during issuance. The serial number layout is:
70
+
71
+ ::
72
+
73
+ [ 16-bit prefix ][ 80-bit encoded name ][ 64-bit random ]
74
+
75
+ Only certificates whose serials were generated by ``SerialWithEncoding``
76
+ (i.e. issued by this library) will decode meaningfully. Certificates
77
+ from external CAs will return ``(None, "")`` or an unrelated string.
78
+
79
+ Parameters
80
+ ----------
81
+ cert : x509.Certificate
82
+ The certificate to decode.
83
+
84
+ Returns
85
+ -------
86
+ tuple[CertType | None, str]
87
+ ``(cert_type, name_prefix)`` where:
88
+
89
+ - *cert_type* is the ``CertType`` enum member extracted from the
90
+ prefix bits, or ``None`` if the prefix is unrecognised.
91
+ - *name_prefix* is the up-to-10-character ASCII string packed into
92
+ the name bits of the serial (may be shorter due to zero-padding).
93
+
94
+ Examples
95
+ --------
96
+ >>> cert_type, name = CertSerialParser.typed(my_cert)
97
+ >>> assert cert_type == CertType.SERVICE
98
+ >>> assert name.startswith("nginx")
99
+ """
100
+ return SerialWithEncoding.parse(cert.serial_number)
101
+
102
+ @staticmethod
103
+ async def raw_async(cert: x509.Certificate) -> int:
104
+ """
105
+ Async-версія :meth:`raw`.
106
+
107
+ Виконує читання в пулі потоків, щоб не блокувати event loop.
108
+
109
+ Parameters
110
+ ----------
111
+ cert : x509.Certificate
112
+ Сертифікат, серійний номер якого потрібно прочитати.
113
+
114
+ Returns
115
+ -------
116
+ int
117
+ Серійний номер як ``int``.
118
+
119
+ Examples
120
+ --------
121
+ #>>> serial = await CertSerialParser.raw_async(my_cert)
122
+ #>>> record = await db.get_by_serial(serial)
123
+ """
124
+ loop = asyncio.get_event_loop()
125
+ return await loop.run_in_executor(None, CertSerialParser.raw, cert)
126
+
127
+ @staticmethod
128
+ async def typed_async(cert: x509.Certificate) -> tuple[CertType | None, str]:
129
+ """
130
+ Async-версія :meth:`typed`.
131
+
132
+ Декодує серійний номер сертифіката у вбудовані тип та ім'я,
133
+ не блокуючи event loop.
134
+
135
+ Parameters
136
+ ----------
137
+ cert : x509.Certificate
138
+ Сертифікат для декодування.
139
+
140
+ Returns
141
+ -------
142
+ tuple[CertType | None, str]
143
+ ``(cert_type, name_prefix)``
144
+
145
+ Examples
146
+ --------
147
+ #>>> cert_type, name = await CertSerialParser.typed_async(my_cert)
148
+ >>> assert name.startswith("nginx")
149
+ """
150
+ loop = asyncio.get_event_loop()
151
+ return await loop.run_in_executor(None, CertSerialParser.typed, cert)
tiny_ca/const.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ const.py
3
+
4
+ Package-level constants and enumerations shared across all ``tiny_ca`` modules.
5
+
6
+ Module-level contents
7
+ ---------------------
8
+ ``ALLOWED_CERT_EXTENSIONS`` -- whitelist of file extensions accepted by the
9
+ CA file loader and storage layer.
10
+ ``CertType`` -- enumeration of certificate categories used to
11
+ embed a human-readable type prefix into X.509
12
+ serial numbers.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+
19
+ #: Tuple of file extensions that the CA loader and storage validator accept.
20
+ #:
21
+ #: Any path whose suffix is not in this tuple will be rejected by
22
+ #: ``CAFileLoader._validate_file`` with a ``WrongType`` exception.
23
+ #:
24
+ #: ======= ===========================
25
+ #: Ext Contents
26
+ #: ======= ===========================
27
+ #: ``.key`` RSA private key (PEM)
28
+ #: ``.pem`` X.509 certificate or CRL (PEM)
29
+ #: ``.csr`` Certificate signing request (PEM)
30
+ #: ======= ===========================
31
+ ALLOWED_CERT_EXTENSIONS: tuple[str, ...] = (".key", ".pem", ".csr")
32
+
33
+
34
+ class CertType(Enum):
35
+ """
36
+ Enumeration of certificate categories issued by the CA.
37
+
38
+ Each member carries a short string value that is used as a human-readable
39
+ prefix when encoding serial numbers via ``_PrefixRegistry`` and
40
+ ``SerialWithEncoding``. The prefix makes it possible to identify the
41
+ certificate category directly from a hex dump of the serial number without
42
+ any additional tooling.
43
+
44
+ Members
45
+ -------
46
+ USER : "USR"
47
+ End-user personal certificate. Issued to individual people for
48
+ authentication, email signing, or client TLS.
49
+ SERVICE : "SVC"
50
+ Service or application certificate. Issued to software services,
51
+ microservices, or API endpoints that need mutual TLS or code signing.
52
+ DEVICE : "DEV"
53
+ Device certificate. Issued to physical or virtual devices (IoT nodes,
54
+ network equipment) that authenticate to the infrastructure.
55
+ INTERNAL : "INT"
56
+ Internal infrastructure certificate. Issued to internal components
57
+ such as monitoring agents, message brokers, or CI runners that need
58
+ identity but are not user-facing.
59
+ CA : "CA"
60
+ Certificate Authority certificate. Used for the root or intermediate
61
+ CA itself; ``BasicConstraints(ca=True)`` is always set for this type.
62
+
63
+ Notes
64
+ -----
65
+ The string values are also stored in the ``key_type`` column of
66
+ ``CertificateRecord`` so that certificate categories are human-readable
67
+ in direct SQL queries.
68
+
69
+ Examples
70
+ --------
71
+ >>> CertType.SERVICE.value
72
+ 'SVC'
73
+ >>> CertType("DEV")
74
+ <CertType.DEVICE: 'DEV'>
75
+ """
76
+
77
+ USER = "USR"
78
+ SERVICE = "SVC"
79
+ DEVICE = "DEV"
80
+ INTERNAL = "INT"
81
+ CA = "CA"
tiny_ca/db/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .base_db import BaseDB
2
+ from .const import CertificateStatus, RevokeStatus
3
+ from .models import CertificateRecord
4
+ from .sync_db_manager import DatabaseManager as SyncDatabaseManager
5
+ from .sync_db_manager import SyncDBHandler
6
+
7
+ __all__ = [
8
+ "CertificateRecord",
9
+ "RevokeStatus",
10
+ "CertificateStatus",
11
+ "BaseDB",
12
+ "SyncDBHandler",
13
+ "SyncDatabaseManager",
14
+ ]
@@ -0,0 +1,358 @@
1
+ from collections.abc import AsyncGenerator
2
+ from datetime import UTC, datetime, timedelta
3
+ from logging import Logger
4
+
5
+ from cryptography import x509
6
+ from cryptography.hazmat._oid import NameOID
7
+ from cryptography.hazmat.primitives import serialization
8
+ from sqlalchemy import Row, delete, select
9
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
10
+
11
+ from tiny_ca.const import CertType
12
+
13
+ from ..settings import DEFAULT_LOGGER
14
+ from .base_db import BaseDB
15
+ from .const import CertificateStatus, RevokeStatus
16
+ from .models import Base, CertificateRecord
17
+
18
+
19
+ class DatabaseManager:
20
+ """
21
+ Manages the SQLAlchemy engine and ``sessionmaker`` factory.
22
+
23
+ Responsibility: connection lifecycle *only*. This class creates the
24
+ engine, optionally initialises the schema, and hands out new ``Session``
25
+ objects on request. It performs no queries and contains no domain logic.
26
+
27
+ Parameters
28
+ ----------
29
+ db_url : str
30
+ SQLAlchemy database URL. Supports any SQLAlchemy-compatible backend,
31
+ e.g. ``"sqlite:///ca.db"`` or
32
+ ``"postgresql+psycopg2://user:pass@host/dbname"``.
33
+ Default: ``"sqlite:///ca_repository.db"``.
34
+ create_all : bool
35
+ When ``True`` (default), calls ``Base.metadata.create_all`` at
36
+ construction so the schema is present before the first query.
37
+ Set to ``False`` when using Alembic or another migration tool that
38
+ manages the schema independently.
39
+ """
40
+
41
+ def __init__(self, db_url: str = "sqlite+aiosqlite:///ca_repository.db"):
42
+ self.engine = create_async_engine(db_url, echo=False)
43
+ self.async_session = async_sessionmaker(
44
+ self.engine, expire_on_commit=False, class_=AsyncSession
45
+ )
46
+
47
+ async def init_db(self) -> None: # pragma: no cover
48
+ async with self.engine.begin() as conn:
49
+ await conn.run_sync(Base.metadata.create_all)
50
+
51
+ def get_session(self) -> AsyncSession:
52
+ return self.async_session()
53
+
54
+
55
+ class AsyncDBHandler(BaseDB):
56
+ """
57
+ Asynchronous, SQLAlchemy-backed certificate registry.
58
+
59
+ Implements the full ``BaseDB`` contract with explicit, atomic transaction
60
+ management. Every public method follows the same pattern:
61
+
62
+ 1. Open a new session via ``self._db.session()``.
63
+ 2. Execute the query / mutation inside a ``try`` block.
64
+ 3. Commit on success or roll back on any exception.
65
+ 4. Always close the session in the ``finally`` block.
66
+
67
+ This guarantees that no session is leaked regardless of outcome, and that
68
+ partial writes are never visible to other readers.
69
+
70
+ Parameters
71
+ ----------
72
+ db_url : str
73
+ SQLAlchemy database URL forwarded to ``DatabaseManager``.
74
+ logger : Logger | None
75
+ Logger for operational and diagnostic messages.
76
+ Falls back to ``DEFAULT_LOGGER`` when ``None``.
77
+ """
78
+
79
+ def __init__(self, db_url: str, logger: Logger | None = None) -> None:
80
+ self._logger = logger or DEFAULT_LOGGER
81
+ self._db = DatabaseManager(db_url=db_url)
82
+
83
+ async def get_by_serial(self, serial: int) -> CertificateRecord | None: # type: ignore
84
+ """
85
+ Fetch a single certificate record by its X.509 serial number.
86
+
87
+ The serial is stored as a string in the database (to avoid integer
88
+ overflow across all backends); the conversion is handled internally.
89
+
90
+ Parameters
91
+ ----------
92
+ serial : int
93
+ Integer serial number to look up.
94
+
95
+ Returns
96
+ -------
97
+ CertificateRecord | None
98
+ The matching ORM record, or ``None`` if no record exists for
99
+ *serial* or if a database error occurs.
100
+ """
101
+
102
+ async with self._db.get_session() as session:
103
+ try:
104
+ stmt = select(CertificateRecord).where(
105
+ CertificateRecord.serial_number == str(serial)
106
+ )
107
+
108
+ result = await session.execute(stmt)
109
+ cert: CertificateRecord | None = result.scalar_one_or_none()
110
+ self._logger.debug("get_by_serial(%d) → %s", serial, cert)
111
+
112
+ return cert
113
+ except Exception as exc:
114
+ self._logger.error(
115
+ "get_by_serial(%d) failed: %s", serial, exc, exc_info=True
116
+ )
117
+ return None
118
+
119
+ async def get_by_name(self, common_name: str) -> CertificateRecord | None: # type: ignore[override]
120
+ """
121
+ Fetch the active VALID certificate record for the given Common Name.
122
+
123
+ Only records with ``status == CertificateStatus.VALID`` are returned.
124
+ Revoked and expired records are ignored so that the caller always
125
+ receives the currently-active certificate for a given CN, or ``None``
126
+ if no active certificate exists.
127
+
128
+ Parameters
129
+ ----------
130
+ common_name : str
131
+ The CN (Common Name) value from the certificate Subject field.
132
+
133
+ Returns
134
+ -------
135
+ CertificateRecord | None
136
+ The matching VALID record, or ``None`` if absent or on DB error.
137
+ """
138
+
139
+ async with self._db.get_session() as session:
140
+ try:
141
+ stmt = select(CertificateRecord).where(
142
+ CertificateRecord.common_name == common_name,
143
+ CertificateRecord.status == CertificateStatus.VALID,
144
+ )
145
+
146
+ result = await session.execute(stmt)
147
+ print(result)
148
+ cert: CertificateRecord | None = result.scalar_one_or_none()
149
+ self._logger.debug("get_by_name(%r) → %s", common_name, cert)
150
+ print(cert)
151
+ return cert
152
+ except Exception as exc:
153
+ self._logger.error(
154
+ "get_by_name(%r) failed: %s", common_name, exc, exc_info=True
155
+ )
156
+ return None
157
+
158
+ async def register_cert_in_db( # type: ignore[override]
159
+ self,
160
+ cert: x509.Certificate,
161
+ uuid: str,
162
+ key_type: CertType = CertType.DEVICE,
163
+ ) -> bool:
164
+ """
165
+ Persist a newly issued certificate to the registry.
166
+
167
+ Creates a new ``CertificateRecord`` row with ``status=VALID`` from the
168
+ metadata and PEM encoding of *cert*. The full PEM is stored so the
169
+ certificate can be reconstructed independently of the filesystem.
170
+
171
+ Parameters
172
+ ----------
173
+ cert : x509.Certificate
174
+ The issued X.509 certificate. Its Subject must contain at least
175
+ one ``commonName`` (CN) attribute.
176
+ uuid : str
177
+ UUID string that identifies the storage folder holding the
178
+ corresponding ``.pem``, ``.key``, and ``.csr`` files.
179
+ key_type : CertType
180
+ Certificate category. Stored as its ``str`` value (e.g.
181
+ ``"device"``). Default: ``CertType.DEVICE``.
182
+
183
+ Returns
184
+ -------
185
+ bool
186
+ ``True`` if the record was committed successfully;
187
+ ``False`` if the operation was rolled back due to an error
188
+ (e.g. duplicate serial, constraint violation).
189
+
190
+ Raises
191
+ ------
192
+ IndexError
193
+ Re-raised if the certificate contains no CN attribute, indicating
194
+ a malformed certificate that should not be stored.
195
+ """
196
+
197
+ async with self._db.get_session() as session:
198
+ try:
199
+ common_name = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[
200
+ 0
201
+ ].value
202
+ common_name = (
203
+ common_name
204
+ if isinstance(common_name, str)
205
+ else common_name.decode("utf-8")
206
+ )
207
+ new_cert = CertificateRecord(
208
+ serial_number=str(cert.serial_number),
209
+ common_name=common_name,
210
+ not_valid_before=cert.not_valid_before_utc,
211
+ not_valid_after=cert.not_valid_after_utc,
212
+ certificate_pem=cert.public_bytes(
213
+ serialization.Encoding.PEM
214
+ ).decode("utf-8"),
215
+ status=CertificateStatus.VALID,
216
+ key_type=key_type.value,
217
+ uuid=uuid,
218
+ )
219
+ session.add(new_cert)
220
+ await session.commit()
221
+ self._logger.info(
222
+ "Certificate registered: CN=%r, serial=%s, uuid=%s",
223
+ common_name,
224
+ cert.serial_number,
225
+ uuid,
226
+ )
227
+ return True
228
+ except Exception as exc:
229
+ await session.rollback()
230
+ self._logger.error(
231
+ "register_cert_in_db failed (serial=%s): %s",
232
+ cert.serial_number,
233
+ exc,
234
+ exc_info=True,
235
+ )
236
+ return False
237
+
238
+ async def revoke_certificate( # type: ignore[override]
239
+ self,
240
+ serial_number: int,
241
+ reason: x509.ReasonFlags = x509.ReasonFlags.unspecified,
242
+ ) -> tuple[bool, RevokeStatus]:
243
+ """
244
+ Mark a certificate as revoked and record the reason and timestamp.
245
+
246
+ Looks up the certificate by *serial_number* filtered to
247
+ ``status == VALID`` — already-revoked or unknown serials are treated
248
+ as not found. On success the record is updated in-place:
249
+ - ``status`` → ``CertificateStatus.REVOKED``
250
+ - ``revocation_reason`` → integer value of *reason*
251
+ - ``revocation_date`` → current UTC timestamp
252
+
253
+ The change is committed atomically; a rollback is performed on any
254
+ unexpected error.
255
+
256
+ Parameters
257
+ ----------
258
+ serial_number : int
259
+ Serial number of the certificate to revoke.
260
+ reason : x509.ReasonFlags
261
+ RFC 5280 §5.3.1 revocation reason code.
262
+ Default: ``x509.ReasonFlags.unspecified`` (code 0).
263
+
264
+ Returns
265
+ -------
266
+ tuple[bool, RevokeStatus]
267
+ ``(True, RevokeStatus.OK)`` — revocation committed.
268
+ ``(False, RevokeStatus.NOT_FOUND)`` — no VALID cert with that serial.
269
+ ``(False, RevokeStatus.UNKNOWN_ERROR)`` — unexpected internal error.
270
+ """
271
+
272
+ async with self._db.get_session() as session:
273
+ try:
274
+ stmt = select(CertificateRecord).where(
275
+ CertificateRecord.serial_number == str(serial_number),
276
+ CertificateRecord.status == CertificateStatus.VALID,
277
+ )
278
+
279
+ result = await session.execute(stmt)
280
+ cert: CertificateRecord | None = result.scalar_one_or_none()
281
+ self._logger.debug("get_by_serial(%d) → %s", serial_number, cert)
282
+
283
+ if cert is None:
284
+ self._logger.warning(
285
+ "revoke_certificate: no VALID record found for serial=%d",
286
+ serial_number,
287
+ )
288
+ return False, RevokeStatus.NOT_FOUND
289
+ cert.status = CertificateStatus.REVOKED
290
+ cert.revocation_reason = reason.value if hasattr(reason, "value") else 0 # type: ignore
291
+ cert.revocation_date = datetime.now(UTC) # type: ignore
292
+
293
+ await session.commit()
294
+ self._logger.info(
295
+ "Certificate revoked: serial=%d, reason=%s", serial_number, reason
296
+ )
297
+ return True, RevokeStatus.OK
298
+
299
+ except Exception as exc:
300
+ await session.rollback()
301
+ self._logger.error(
302
+ "revoke_certificate(%d) failed: %s",
303
+ serial_number,
304
+ exc,
305
+ exc_info=True,
306
+ )
307
+ return False, RevokeStatus.UNKNOWN_ERROR
308
+
309
+ async def get_revoked_certificates( # type: ignore
310
+ self,
311
+ ) -> AsyncGenerator[Row[tuple[str, datetime, str]], None]: # type: ignore
312
+ """
313
+ Yield revoked certificate rows relevant for the current CRL window.
314
+
315
+ A record is included when **all** of the following conditions hold:
316
+
317
+ 1. ``revocation_date`` is not ``NULL`` — the certificate was actually revoked.
318
+ 2. ``not_valid_after > now`` — the certificate has not yet expired; expired
319
+ certificates need not appear in a CRL because relying parties will
320
+ reject them regardless.
321
+ 3. ``revocation_date > now - 365 days`` — the revocation is recent enough
322
+ to be relevant. This prevents unbounded CRL growth from very old
323
+ entries that no relying party could still encounter.
324
+
325
+ Only three columns are selected (``serial_number``, ``revocation_date``,
326
+ ``revocation_reason``) to minimise data transfer; callers must not
327
+ access other ``CertificateRecord`` attributes on the yielded rows.
328
+
329
+ Yields
330
+ ------
331
+ CertificateRecord
332
+ SQLAlchemy ``Row`` objects with ``serial_number``,
333
+ ``revocation_date``, and ``revocation_reason`` attributes.
334
+
335
+ Notes
336
+ -----
337
+ All rows are fetched in a single query before yielding begins. The
338
+ session is closed in the ``finally`` block; do not use the yielded
339
+ rows after the generator has been exhausted or abandoned.
340
+ """
341
+
342
+ now = datetime.now(UTC)
343
+ cutoff = now - timedelta(days=365)
344
+
345
+ async with self._db.get_session() as session:
346
+ stmt = select(
347
+ CertificateRecord.serial_number,
348
+ CertificateRecord.revocation_date,
349
+ CertificateRecord.revocation_reason,
350
+ ).where(
351
+ CertificateRecord.revocation_date.isnot(None),
352
+ CertificateRecord.not_valid_after > now,
353
+ CertificateRecord.revocation_date > cutoff,
354
+ )
355
+ result = await session.stream(stmt)
356
+
357
+ async for row in result:
358
+ yield row