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,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from logging import Logger
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol, cast, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from cryptography import x509
|
|
9
|
+
from cryptography.hazmat._oid import NameOID
|
|
10
|
+
from cryptography.hazmat.backends import default_backend
|
|
11
|
+
from cryptography.hazmat.bindings._rust import ObjectIdentifier
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
14
|
+
|
|
15
|
+
from tiny_ca.const import ALLOWED_CERT_EXTENSIONS
|
|
16
|
+
from tiny_ca.exc import ErrorLoadCert, IsNotFile, NotExistCertFile, WrongType
|
|
17
|
+
from tiny_ca.models.certtificate import CertificateInfo
|
|
18
|
+
from tiny_ca.settings import DEFAULT_LOGGER
|
|
19
|
+
|
|
20
|
+
from .file_loader import CAFileLoader
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncCAFileLoader:
|
|
24
|
+
"""
|
|
25
|
+
Loads a CA certificate and private key from PEM files on the local filesystem.
|
|
26
|
+
|
|
27
|
+
Responsibility: file reading and PEM deserialisation *only*.
|
|
28
|
+
Does not generate certificates, manage sessions, or perform any
|
|
29
|
+
cryptographic operations beyond deserialisation.
|
|
30
|
+
|
|
31
|
+
On construction the loader:
|
|
32
|
+
1. Validates that both paths point to existing, regular files with
|
|
33
|
+
permitted extensions (see ``ALLOWED_CERT_EXTENSIONS``).
|
|
34
|
+
2. Deserialises the CA certificate and private key from PEM.
|
|
35
|
+
3. Extracts ``CertificateInfo`` from the CA certificate's Subject.
|
|
36
|
+
|
|
37
|
+
After successful construction all three ``ICALoader`` properties are
|
|
38
|
+
available and will not change for the lifetime of the instance.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
ca_cert_path : str | Path
|
|
43
|
+
Path to the PEM-encoded CA certificate file.
|
|
44
|
+
ca_key_path : str | Path
|
|
45
|
+
Path to the PEM-encoded CA private key file.
|
|
46
|
+
ca_key_password : str | bytes | None
|
|
47
|
+
Optional password protecting the private key. A ``str`` value is
|
|
48
|
+
encoded to ``bytes`` using UTF-8 before being passed to the
|
|
49
|
+
cryptography library. ``None`` means the key is unencrypted.
|
|
50
|
+
logger : Logger | None
|
|
51
|
+
Logger instance for diagnostic messages. Falls back to
|
|
52
|
+
``DEFAULT_LOGGER`` when ``None``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
ca_cert_path: str | Path,
|
|
58
|
+
ca_key_path: str | Path,
|
|
59
|
+
ca_key_password: str | bytes | None = None,
|
|
60
|
+
logger: Logger | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._logger = logger or DEFAULT_LOGGER
|
|
63
|
+
self._ca_cert_path = CAFileLoader._validate_file(Path(ca_cert_path))
|
|
64
|
+
self._ca_key_path = CAFileLoader._validate_file(Path(ca_key_path))
|
|
65
|
+
|
|
66
|
+
self._password_bytes: bytes | None = None
|
|
67
|
+
if ca_key_password is not None:
|
|
68
|
+
self._password_bytes = (
|
|
69
|
+
ca_key_password.encode("utf-8")
|
|
70
|
+
if isinstance(ca_key_password, str)
|
|
71
|
+
else ca_key_password
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self._ca_cert: x509.Certificate | None = None
|
|
75
|
+
self._ca_key: rsa.RSAPrivateKey | None = None
|
|
76
|
+
self._base_info: CertificateInfo | None = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
async def create(
|
|
80
|
+
cls,
|
|
81
|
+
ca_cert_path: str | Path,
|
|
82
|
+
ca_key_path: str | Path,
|
|
83
|
+
ca_key_password: str | bytes | None = None,
|
|
84
|
+
logger: Logger | None = None,
|
|
85
|
+
) -> AsyncCAFileLoader:
|
|
86
|
+
|
|
87
|
+
instance = cls(ca_cert_path, ca_key_path, ca_key_password, logger)
|
|
88
|
+
await instance.load()
|
|
89
|
+
return instance
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# ICALoader properties
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ca_cert(self) -> x509.Certificate:
|
|
97
|
+
if self._ca_cert is None:
|
|
98
|
+
raise RuntimeError("Call 'await loader.load()' before accessing ca_cert.")
|
|
99
|
+
return self._ca_cert
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def ca_key(self) -> rsa.RSAPrivateKey:
|
|
103
|
+
if self._ca_key is None:
|
|
104
|
+
raise RuntimeError("Call 'await loader.load()' before accessing ca_key.")
|
|
105
|
+
return self._ca_key
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def base_info(self) -> CertificateInfo:
|
|
109
|
+
if self._base_info is None:
|
|
110
|
+
raise RuntimeError("Call 'await loader.load()' before accessing base_info.")
|
|
111
|
+
return self._base_info
|
|
112
|
+
|
|
113
|
+
async def load(self) -> None:
|
|
114
|
+
loop = asyncio.get_event_loop()
|
|
115
|
+
ca_cert, ca_key = await loop.run_in_executor(
|
|
116
|
+
None,
|
|
117
|
+
self._load_sync,
|
|
118
|
+
)
|
|
119
|
+
self._ca_cert = ca_cert
|
|
120
|
+
self._ca_key = ca_key
|
|
121
|
+
self._base_info = self._extract_info()
|
|
122
|
+
|
|
123
|
+
def _load_sync(self) -> tuple[x509.Certificate, rsa.RSAPrivateKey]:
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with open(self._ca_cert_path, "rb") as fh:
|
|
127
|
+
ca_cert = x509.load_pem_x509_certificate(fh.read(), default_backend())
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise ErrorLoadCert(path_to_file=self._ca_cert_path, exc=str(exc)) from exc
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with open(self._ca_key_path, "rb") as fh:
|
|
133
|
+
ca_key = serialization.load_pem_private_key(
|
|
134
|
+
fh.read(), password=self._password_bytes, backend=default_backend()
|
|
135
|
+
)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
raise ErrorLoadCert(path_to_file=self._ca_key_path, exc=str(exc)) from exc
|
|
138
|
+
|
|
139
|
+
self._logger.info("CA loaded successfully from %s", self._ca_cert_path)
|
|
140
|
+
if not isinstance(ca_key, rsa.RSAPrivateKey):
|
|
141
|
+
raise TypeError("CA key must be RSA")
|
|
142
|
+
return ca_cert, ca_key
|
|
143
|
+
|
|
144
|
+
def _extract_info(self) -> CertificateInfo:
|
|
145
|
+
# assert self._ca_cert is not None
|
|
146
|
+
|
|
147
|
+
def _attr(oid: ObjectIdentifier) -> str | None:
|
|
148
|
+
attrs = self._ca_cert.subject.get_attributes_for_oid(oid) # type: ignore[union-attr]
|
|
149
|
+
return cast(str, attrs[0].value) if attrs else None
|
|
150
|
+
|
|
151
|
+
return CertificateInfo(
|
|
152
|
+
organization=_attr(NameOID.ORGANIZATION_NAME),
|
|
153
|
+
organizational_unit=_attr(NameOID.ORGANIZATIONAL_UNIT_NAME),
|
|
154
|
+
country=_attr(NameOID.COUNTRY_NAME),
|
|
155
|
+
state=_attr(NameOID.STATE_OR_PROVINCE_NAME),
|
|
156
|
+
locality=_attr(NameOID.LOCALITY_NAME),
|
|
157
|
+
)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CA material loading: Protocol definition and PEM-file-backed implementation.
|
|
3
|
+
|
|
4
|
+
This module provides two public symbols:
|
|
5
|
+
|
|
6
|
+
- ``ICALoader`` — a ``@runtime_checkable`` Protocol that defines the minimum
|
|
7
|
+
interface any CA-material provider must satisfy. Consumers
|
|
8
|
+
(e.g. ``CertificateFactory``) depend only on this Protocol,
|
|
9
|
+
never on a concrete loader class (DIP).
|
|
10
|
+
|
|
11
|
+
- ``CAFileLoader`` — reads a CA certificate and private key from PEM files on
|
|
12
|
+
the local filesystem and exposes them through ``ICALoader``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from logging import Logger
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Protocol, cast, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from cryptography import x509
|
|
22
|
+
from cryptography.hazmat._oid import NameOID
|
|
23
|
+
from cryptography.hazmat.backends import default_backend
|
|
24
|
+
from cryptography.hazmat.bindings._rust import ObjectIdentifier
|
|
25
|
+
from cryptography.hazmat.primitives import serialization
|
|
26
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
27
|
+
|
|
28
|
+
from tiny_ca.const import ALLOWED_CERT_EXTENSIONS
|
|
29
|
+
from tiny_ca.exc import ErrorLoadCert, IsNotFile, NotExistCertFile, WrongType
|
|
30
|
+
from tiny_ca.models.certtificate import CertificateInfo
|
|
31
|
+
from tiny_ca.settings import DEFAULT_LOGGER
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# ISP / DIP: minimal interface for CA-material providers
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class ICALoader(Protocol):
|
|
40
|
+
"""
|
|
41
|
+
Protocol that defines the minimum contract for CA-material providers.
|
|
42
|
+
|
|
43
|
+
Any object that exposes the three properties below satisfies this Protocol
|
|
44
|
+
and can be injected into ``CertificateFactory`` without any inheritance.
|
|
45
|
+
This makes it trivial to substitute the real filesystem loader with an
|
|
46
|
+
in-memory stub, an HSM-backed loader, or a mock in unit tests.
|
|
47
|
+
|
|
48
|
+
Properties
|
|
49
|
+
----------
|
|
50
|
+
ca_cert : x509.Certificate
|
|
51
|
+
The loaded CA certificate object.
|
|
52
|
+
ca_key : rsa.RSAPrivateKey
|
|
53
|
+
The loaded CA private key used for signing.
|
|
54
|
+
base_info : CertificateInfo
|
|
55
|
+
Structured metadata extracted from the CA certificate's Subject field
|
|
56
|
+
(organization, country, state, locality, organizational unit).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def ca_cert(self) -> x509.Certificate: ...
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ca_key(self) -> rsa.RSAPrivateKey: ...
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def base_info(self) -> CertificateInfo: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# SRP: PEM-file-backed CA loader
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CAFileLoader:
|
|
75
|
+
"""
|
|
76
|
+
Loads a CA certificate and private key from PEM files on the local filesystem.
|
|
77
|
+
|
|
78
|
+
Responsibility: file reading and PEM deserialisation *only*.
|
|
79
|
+
Does not generate certificates, manage sessions, or perform any
|
|
80
|
+
cryptographic operations beyond deserialisation.
|
|
81
|
+
|
|
82
|
+
On construction the loader:
|
|
83
|
+
1. Validates that both paths point to existing, regular files with
|
|
84
|
+
permitted extensions (see ``ALLOWED_CERT_EXTENSIONS``).
|
|
85
|
+
2. Deserialises the CA certificate and private key from PEM.
|
|
86
|
+
3. Extracts ``CertificateInfo`` from the CA certificate's Subject.
|
|
87
|
+
|
|
88
|
+
After successful construction all three ``ICALoader`` properties are
|
|
89
|
+
available and will not change for the lifetime of the instance.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
ca_cert_path : str | Path
|
|
94
|
+
Path to the PEM-encoded CA certificate file.
|
|
95
|
+
ca_key_path : str | Path
|
|
96
|
+
Path to the PEM-encoded CA private key file.
|
|
97
|
+
ca_key_password : str | bytes | None
|
|
98
|
+
Optional password protecting the private key. A ``str`` value is
|
|
99
|
+
encoded to ``bytes`` using UTF-8 before being passed to the
|
|
100
|
+
cryptography library. ``None`` means the key is unencrypted.
|
|
101
|
+
logger : Logger | None
|
|
102
|
+
Logger instance for diagnostic messages. Falls back to
|
|
103
|
+
``DEFAULT_LOGGER`` when ``None``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# ICALoader properties
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def ca_cert(self) -> x509.Certificate:
|
|
112
|
+
"""
|
|
113
|
+
The deserialized CA certificate.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
x509.Certificate
|
|
118
|
+
The CA certificate loaded from *ca_cert_path*.
|
|
119
|
+
"""
|
|
120
|
+
return self._ca_cert
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def ca_key(self) -> rsa.RSAPrivateKey:
|
|
124
|
+
"""
|
|
125
|
+
The deserialized CA private key.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
rsa.RSAPrivateKey
|
|
130
|
+
The private key loaded from *ca_key_path*.
|
|
131
|
+
"""
|
|
132
|
+
return self._ca_key
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def base_info(self) -> CertificateInfo:
|
|
136
|
+
"""
|
|
137
|
+
Structured metadata extracted from the CA certificate Subject.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
CertificateInfo
|
|
142
|
+
Contains *organization*, *organizational_unit*, *country*,
|
|
143
|
+
*state*, and *locality* fields; any absent attribute is ``None``.
|
|
144
|
+
"""
|
|
145
|
+
return self._base_info
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Construction
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
ca_cert_path: str | Path,
|
|
154
|
+
ca_key_path: str | Path,
|
|
155
|
+
ca_key_password: str | bytes | None = None,
|
|
156
|
+
logger: Logger | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._logger = logger or DEFAULT_LOGGER
|
|
159
|
+
|
|
160
|
+
self._ca_cert_path = self._validate_file(Path(ca_cert_path))
|
|
161
|
+
self._ca_key_path = self._validate_file(Path(ca_key_path))
|
|
162
|
+
|
|
163
|
+
# Normalise password to bytes so the cryptography library always
|
|
164
|
+
# receives the expected type.
|
|
165
|
+
password_bytes: bytes | None = None
|
|
166
|
+
if ca_key_password is not None:
|
|
167
|
+
password_bytes = (
|
|
168
|
+
ca_key_password.encode("utf-8")
|
|
169
|
+
if isinstance(ca_key_password, str)
|
|
170
|
+
else ca_key_password
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
self._ca_cert, self._ca_key = self._load(password_bytes)
|
|
174
|
+
self._base_info = self._extract_info()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Private helpers
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _validate_file(
|
|
182
|
+
path: Path,
|
|
183
|
+
allowed: tuple[str, ...] = ALLOWED_CERT_EXTENSIONS,
|
|
184
|
+
) -> Path:
|
|
185
|
+
"""
|
|
186
|
+
Verify that *path* points to an existing regular file with a
|
|
187
|
+
permitted extension.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
path : Path
|
|
192
|
+
Filesystem path to validate.
|
|
193
|
+
allowed : tuple[str, ...]
|
|
194
|
+
Whitelist of accepted file extensions (including the leading dot,
|
|
195
|
+
e.g. ``".pem"``). Defaults to ``ALLOWED_CERT_EXTENSIONS``.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
Path
|
|
200
|
+
The validated *path*, unchanged.
|
|
201
|
+
|
|
202
|
+
Raises
|
|
203
|
+
------
|
|
204
|
+
NotExistCertFile
|
|
205
|
+
If *path* does not exist on the filesystem.
|
|
206
|
+
IsNotFile
|
|
207
|
+
If *path* exists but is a directory or other non-regular file.
|
|
208
|
+
WrongType
|
|
209
|
+
If the file extension is not listed in *allowed*.
|
|
210
|
+
"""
|
|
211
|
+
if not path.exists():
|
|
212
|
+
raise NotExistCertFile(path_to_file=path)
|
|
213
|
+
if not path.is_file():
|
|
214
|
+
raise IsNotFile(path_to_file=path)
|
|
215
|
+
if path.suffix not in allowed:
|
|
216
|
+
raise WrongType(wrong_type=path.suffix, allowed_types=allowed)
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
def _load(
|
|
220
|
+
self,
|
|
221
|
+
password: bytes | None,
|
|
222
|
+
) -> tuple[x509.Certificate, rsa.RSAPrivateKey]:
|
|
223
|
+
"""
|
|
224
|
+
Read and deserialise the CA certificate and private key from PEM files.
|
|
225
|
+
|
|
226
|
+
The certificate and key are loaded independently; a separate
|
|
227
|
+
``try/except`` block wraps each operation so that the failure message
|
|
228
|
+
identifies exactly which file could not be read.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
password : bytes | None
|
|
233
|
+
Password for the private key, or ``None`` for unencrypted keys.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
tuple[x509.Certificate, rsa.RSAPrivateKey]
|
|
238
|
+
``(ca_cert, ca_key)`` ready for use.
|
|
239
|
+
|
|
240
|
+
Raises
|
|
241
|
+
------
|
|
242
|
+
ErrorLoadCert
|
|
243
|
+
Wraps any underlying exception thrown during file I/O or PEM
|
|
244
|
+
deserialisation, annotated with the offending file path.
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
with open(self._ca_cert_path, "rb") as fh:
|
|
248
|
+
ca_cert = x509.load_pem_x509_certificate(fh.read(), default_backend())
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
raise ErrorLoadCert(path_to_file=self._ca_cert_path, exc=str(exc)) from exc
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
with open(self._ca_key_path, "rb") as fh:
|
|
254
|
+
ca_key = serialization.load_pem_private_key(
|
|
255
|
+
fh.read(), password=password, backend=default_backend()
|
|
256
|
+
)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
raise ErrorLoadCert(path_to_file=self._ca_key_path, exc=str(exc)) from exc
|
|
259
|
+
|
|
260
|
+
self._logger.info("CA loaded successfully from %s", self._ca_cert_path)
|
|
261
|
+
if not isinstance(ca_key, rsa.RSAPrivateKey):
|
|
262
|
+
raise TypeError("CA key must be RSA")
|
|
263
|
+
return ca_cert, ca_key
|
|
264
|
+
|
|
265
|
+
def _extract_info(self) -> CertificateInfo:
|
|
266
|
+
"""
|
|
267
|
+
Parse the CA certificate's Subject and return a structured info object.
|
|
268
|
+
|
|
269
|
+
Iterates over well-known OIDs (organization, organizational unit,
|
|
270
|
+
country, state, locality) and reads the first matching attribute value
|
|
271
|
+
for each. Missing attributes are stored as ``None``.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
CertificateInfo
|
|
276
|
+
Dataclass populated with Subject attribute values extracted from
|
|
277
|
+
``self._ca_cert``.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def _attr(oid: ObjectIdentifier) -> str | None:
|
|
281
|
+
attrs = self._ca_cert.subject.get_attributes_for_oid(oid)
|
|
282
|
+
return cast(str, attrs[0].value) if attrs else None
|
|
283
|
+
|
|
284
|
+
return CertificateInfo(
|
|
285
|
+
organization=_attr(NameOID.ORGANIZATION_NAME),
|
|
286
|
+
organizational_unit=_attr(NameOID.ORGANIZATIONAL_UNIT_NAME),
|
|
287
|
+
country=_attr(NameOID.COUNTRY_NAME),
|
|
288
|
+
state=_attr(NameOID.STATE_OR_PROVINCE_NAME),
|
|
289
|
+
locality=_attr(NameOID.LOCALITY_NAME),
|
|
290
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stateless utilities for computing and inspecting X.509 certificate validity
|
|
3
|
+
periods.
|
|
4
|
+
|
|
5
|
+
All methods are ``@staticmethod``; the class carries no instance state and
|
|
6
|
+
exists purely as a logical namespace. It can be used from any module without
|
|
7
|
+
instantiation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
from cryptography import x509
|
|
17
|
+
|
|
18
|
+
from tiny_ca.exc import InvalidRangeTimeCertificate
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CertLifetime:
|
|
22
|
+
"""
|
|
23
|
+
Stateless helper that computes and inspects X.509 certificate validity windows.
|
|
24
|
+
|
|
25
|
+
All operations are pure functions (no side effects, no shared state) and are
|
|
26
|
+
therefore safe to call from multiple threads simultaneously.
|
|
27
|
+
|
|
28
|
+
Use this class to:
|
|
29
|
+
- Compute a ``(not_before, not_after)`` pair for a new certificate.
|
|
30
|
+
- Extract the ``not_valid_after`` / ``not_valid_before`` timestamps from an
|
|
31
|
+
existing certificate as timezone-aware UTC ``datetime`` objects.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def compute(
|
|
36
|
+
valid_from: datetime | None = None,
|
|
37
|
+
days_valid: int = 365,
|
|
38
|
+
) -> tuple[datetime, datetime]:
|
|
39
|
+
"""
|
|
40
|
+
Calculate the ``(not_before, not_after)`` validity interval for a new
|
|
41
|
+
certificate.
|
|
42
|
+
|
|
43
|
+
If *valid_from* is ``None`` the current UTC time is used as the start
|
|
44
|
+
of the interval. The end of the interval is *valid_from* plus
|
|
45
|
+
*days_valid* calendar days.
|
|
46
|
+
|
|
47
|
+
The result is validated to ensure the computed end date has not already
|
|
48
|
+
passed (which would produce an immediately-invalid certificate).
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
valid_from : datetime | None
|
|
53
|
+
Start of the validity period as a timezone-aware ``datetime``.
|
|
54
|
+
Pass ``None`` to use ``datetime.now(timezone.utc)`` automatically.
|
|
55
|
+
days_valid : int
|
|
56
|
+
Number of calendar days the certificate should remain valid.
|
|
57
|
+
Default: ``365`` (one year).
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
tuple[datetime, datetime]
|
|
62
|
+
``(not_before, not_after)`` both expressed in UTC with
|
|
63
|
+
``tzinfo=timezone.utc``.
|
|
64
|
+
|
|
65
|
+
Raises
|
|
66
|
+
------
|
|
67
|
+
InvalidRangeTimeCertificate
|
|
68
|
+
If the computed *not_after* is earlier than the current UTC time,
|
|
69
|
+
meaning the certificate would be expired immediately upon issuance.
|
|
70
|
+
|
|
71
|
+
Examples
|
|
72
|
+
--------
|
|
73
|
+
>>> start, end = CertLifetime.compute(days_valid=90)
|
|
74
|
+
>>> assert (end - start).days == 90
|
|
75
|
+
"""
|
|
76
|
+
now = datetime.now(UTC)
|
|
77
|
+
start = valid_from or now
|
|
78
|
+
end = start + timedelta(days=days_valid)
|
|
79
|
+
if end < now:
|
|
80
|
+
raise InvalidRangeTimeCertificate(valid_from=start, valid_to=end, now=now)
|
|
81
|
+
return start, end
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def valid_to(cert: x509.Certificate) -> datetime:
|
|
85
|
+
"""
|
|
86
|
+
Return the expiry timestamp of *cert* as a timezone-aware UTC datetime.
|
|
87
|
+
|
|
88
|
+
Wraps ``cert.not_valid_after_utc`` and ensures the returned value
|
|
89
|
+
always carries ``tzinfo=timezone.utc`` for safe comparison with other
|
|
90
|
+
aware datetimes.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
cert : x509.Certificate
|
|
95
|
+
The certificate whose expiry date should be read.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
datetime
|
|
100
|
+
``cert.not_valid_after_utc`` with ``tzinfo`` explicitly set to
|
|
101
|
+
``timezone.utc``.
|
|
102
|
+
"""
|
|
103
|
+
return cert.not_valid_after_utc.replace(tzinfo=UTC)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def valid_from(cert: x509.Certificate) -> datetime:
|
|
107
|
+
"""
|
|
108
|
+
Return the activation timestamp of *cert* as a timezone-aware UTC datetime.
|
|
109
|
+
|
|
110
|
+
Wraps ``cert.not_valid_before_utc`` and ensures the returned value
|
|
111
|
+
always carries ``tzinfo=timezone.utc`` for safe comparison with other
|
|
112
|
+
aware datetimes.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
cert : x509.Certificate
|
|
117
|
+
The certificate whose activation date should be read.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
datetime
|
|
122
|
+
``cert.not_valid_before_utc`` with ``tzinfo`` explicitly set to
|
|
123
|
+
``timezone.utc``.
|
|
124
|
+
"""
|
|
125
|
+
return cert.not_valid_after_utc.replace(tzinfo=UTC)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
async def compute_async(
|
|
129
|
+
valid_from: datetime | None = None,
|
|
130
|
+
days_valid: int = 365,
|
|
131
|
+
) -> tuple[datetime, datetime]:
|
|
132
|
+
"""
|
|
133
|
+
Async-версія :meth:`compute`.
|
|
134
|
+
|
|
135
|
+
Виконує обчислення в пулі потоків, щоб не блокувати event loop.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
valid_from : datetime | None
|
|
140
|
+
Початок вікна дійсності (UTC). ``None`` → поточний UTC-час.
|
|
141
|
+
days_valid : int
|
|
142
|
+
Тривалість у календарних днях. За замовчуванням: ``365``.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
tuple[datetime, datetime]
|
|
147
|
+
``(not_before, not_after)`` в UTC.
|
|
148
|
+
|
|
149
|
+
Raises
|
|
150
|
+
------
|
|
151
|
+
InvalidRangeTimeCertificate
|
|
152
|
+
Якщо обчислена дата закінчення вже в минулому.
|
|
153
|
+
|
|
154
|
+
Examples
|
|
155
|
+
--------
|
|
156
|
+
#>>> start, end = await CertLifetime.compute_async(days_valid=90)
|
|
157
|
+
#>>> assert (end - start).days == 90
|
|
158
|
+
"""
|
|
159
|
+
loop = asyncio.get_event_loop()
|
|
160
|
+
return await loop.run_in_executor(
|
|
161
|
+
None,
|
|
162
|
+
lambda: CertLifetime.compute(valid_from, days_valid),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
async def valid_to_async(cert: x509.Certificate) -> datetime:
|
|
167
|
+
"""
|
|
168
|
+
Async-версія :meth:`valid_to`.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
cert : x509.Certificate
|
|
173
|
+
Сертифікат, дату закінчення якого потрібно прочитати.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
datetime
|
|
178
|
+
``cert.not_valid_after_utc`` з ``tzinfo=UTC``.
|
|
179
|
+
"""
|
|
180
|
+
loop = asyncio.get_event_loop()
|
|
181
|
+
return await loop.run_in_executor(None, CertLifetime.valid_to, cert)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
async def valid_from_async(cert: x509.Certificate) -> datetime:
|
|
185
|
+
"""
|
|
186
|
+
Async-версія :meth:`valid_from`.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
cert : x509.Certificate
|
|
191
|
+
Сертифікат, дату початку дії якого потрібно прочитати.
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
datetime
|
|
196
|
+
``cert.not_valid_before_utc`` з ``tzinfo=UTC``.
|
|
197
|
+
"""
|
|
198
|
+
loop = asyncio.get_event_loop()
|
|
199
|
+
return await loop.run_in_executor(None, CertLifetime.valid_from, cert)
|