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,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
|