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,3 @@
1
+ from .certtificate import CAConfig, ClientConfig
2
+
3
+ __all__ = ["CAConfig", "ClientConfig"]
@@ -0,0 +1,300 @@
1
+ """
2
+ certtificate.py
3
+
4
+ Pydantic data models for certificate configuration and metadata.
5
+
6
+ These models act as typed, validated data-transfer objects between the
7
+ application layer and the cryptographic factory. They are intentionally
8
+ free of any cryptographic logic — validation (field constraints, type
9
+ coercion) is Pydantic's responsibility; generation is ``CertificateFactory``'s.
10
+
11
+ Class hierarchy
12
+ ---------------
13
+ ::
14
+
15
+ BaseModel
16
+ ├── CommonNameCertificate — CN field only
17
+ │ └── BaseCertificateDataModel — CN + organization + country
18
+ ├── BaseCertificateConfig — key_size, days_valid, valid_from
19
+ │ └── CAConfig — full CA bootstrap config (inherits both branches)
20
+ └── ClientConfig — end-entity cert config (CN + crypto params + SANs)
21
+
22
+ CertificateInfo — read-only metadata extracted from an
23
+ existing CA certificate's Subject field
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime
29
+
30
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field, IPvAnyAddress
31
+
32
+ from tiny_ca.const import CertType
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Reusable base fragments
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ class CommonNameCertificate(BaseModel):
40
+ """
41
+ Minimal certificate identity fragment carrying only the Common Name.
42
+
43
+ Used as a mix-in base so that all certificate configuration models share
44
+ a consistent ``common_name`` field with the same default and description.
45
+
46
+ Attributes
47
+ ----------
48
+ common_name : str
49
+ The ``CN`` (Common Name) field of the certificate Subject.
50
+ Default: ``"Internal CA"``.
51
+ """
52
+
53
+ common_name: str = Field(
54
+ default="Internal CA",
55
+ description="Common Name (CN) for the certificate Subject.",
56
+ )
57
+
58
+
59
+ class BaseCertificateDataModel(CommonNameCertificate):
60
+ """
61
+ Certificate Subject identity fields shared by all CA configurations.
62
+
63
+ Extends ``CommonNameCertificate`` with *organization* and *country* so
64
+ that all issued end-entity certificates can inherit the issuer's
65
+ organisational identity.
66
+
67
+ Attributes
68
+ ----------
69
+ common_name : str
70
+ Inherited from ``CommonNameCertificate``.
71
+ organization : str
72
+ Organization (O) field of the certificate Subject.
73
+ Default: ``"My Company"``.
74
+ country : str
75
+ Two-letter ISO 3166-1 alpha-2 country code (C field).
76
+ Default: ``"UA"``.
77
+ """
78
+
79
+ organization: str = Field(
80
+ default="My Company",
81
+ description="Organization (O) field of the certificate Subject.",
82
+ )
83
+ country: str = Field(
84
+ default="UA",
85
+ description="Two-letter ISO 3166-1 alpha-2 country code (C field).",
86
+ )
87
+
88
+
89
+ class BaseCertificateConfig(BaseModel):
90
+ """
91
+ Cryptographic validity parameters shared by all certificate types.
92
+
93
+ Provides the three fields that control the key strength and the validity
94
+ window of a certificate. Used as a mix-in base to avoid duplication
95
+ between ``CAConfig`` and ``ClientConfig``.
96
+
97
+ Attributes
98
+ ----------
99
+ key_size : int
100
+ RSA key length in bits. Use ``2048`` for standard security or
101
+ ``4096`` for long-lived CA certificates. Default: ``2048``.
102
+ days_valid : int
103
+ Number of calendar days the certificate should remain valid.
104
+ Default: ``3650`` (≈10 years; appropriate for a root CA).
105
+ valid_from : datetime.datetime | None
106
+ Explicit start of the validity period as a timezone-aware
107
+ ``datetime``. When ``None``, the cryptographic layer uses the
108
+ current UTC time at the moment of issuance. Default: ``None``.
109
+
110
+ Notes
111
+ -----
112
+ ``model_config = ConfigDict(arbitrary_types_allowed=True)`` is required
113
+ because ``datetime.datetime`` is not a Pydantic-native field type.
114
+ """
115
+
116
+ key_size: int = Field(
117
+ default=2048,
118
+ description="RSA key length in bits (2048 or 4096).",
119
+ )
120
+ days_valid: int = Field(
121
+ default=3650,
122
+ description="Certificate validity period in calendar days.",
123
+ )
124
+ valid_from: datetime.datetime | None = Field(
125
+ default=None,
126
+ description=(
127
+ "Explicit validity start as a timezone-aware datetime. "
128
+ "None defaults to the current UTC time at issuance."
129
+ ),
130
+ )
131
+
132
+ model_config = ConfigDict(arbitrary_types_allowed=True)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Concrete configuration models
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ class CAConfig(BaseCertificateConfig, BaseCertificateDataModel):
141
+ """
142
+ Complete configuration for bootstrapping a self-signed root CA certificate.
143
+
144
+ Combines all Subject identity fields (``BaseCertificateDataModel``) with
145
+ the cryptographic validity parameters (``BaseCertificateConfig``).
146
+ Passed directly to ``CertificateFactory.build_self_signed_ca`` via
147
+ ``config.model_dump()``.
148
+
149
+ Inherited attributes
150
+ --------------------
151
+ common_name : str
152
+ CN for the CA. Default: ``"Internal CA"``.
153
+ organization : str
154
+ O field. Default: ``"My Company"``.
155
+ country : str
156
+ C field (ISO 3166-1 alpha-2). Default: ``"UA"``.
157
+ key_size : int
158
+ RSA key size in bits. Default: ``2048``.
159
+ days_valid : int
160
+ Validity in days. Default: ``3650``.
161
+ valid_from : datetime | None
162
+ Explicit validity start; ``None`` uses current UTC. Default: ``None``.
163
+ """
164
+
165
+
166
+ class ClientConfig(CommonNameCertificate, BaseCertificateConfig):
167
+ """
168
+ Configuration for issuing an end-entity (client or server) certificate.
169
+
170
+ Passed to ``CertificateFactory.issue_certificate`` via
171
+ ``config.model_dump(exclude={"name"})``.
172
+
173
+ Attributes
174
+ ----------
175
+ common_name : str
176
+ CN for the certificate Subject. Inherited from
177
+ ``CommonNameCertificate``. Default: ``"Internal CA"``.
178
+ key_size : int
179
+ RSA key size in bits. Inherited from ``BaseCertificateConfig``.
180
+ Default: ``2048``.
181
+ days_valid : int
182
+ Validity period in calendar days. Default: ``3650``.
183
+ valid_from : datetime | None
184
+ Explicit validity start. Default: ``None``.
185
+ serial_type : CertType
186
+ Certificate category used when encoding the serial number.
187
+ Default: ``CertType.CA``.
188
+ is_client_cert : bool
189
+ When ``True``, ``ClientAuth`` is added to the Extended Key Usage
190
+ extension. Default: ``False``.
191
+ is_server_cert : bool
192
+ When ``True``, ``ServerAuth`` is added to the Extended Key Usage
193
+ extension and the CN is included as a DNS Subject Alternative Name
194
+ (RFC 2818 compliance). Default: ``True``.
195
+ san_dns : list[str] | None
196
+ Additional DNS names for the Subject Alternative Name extension.
197
+ Default: ``None``.
198
+ san_ip : list[IPvAnyAddress] | None
199
+ IP addresses for the Subject Alternative Name extension. Accepts
200
+ both IPv4 and IPv6. Default: ``None``.
201
+ email : EmailStr | None
202
+ Optional email address added as an ``emailAddress`` Subject attribute.
203
+ Must be a valid RFC 5322 address if provided. Default: ``None``.
204
+ name : str | None
205
+ Override for the output file basename used by ``BaseStorage``.
206
+ When ``None``, the storage layer derives the name from *common_name*.
207
+ This field is excluded from ``model_dump`` calls to the factory.
208
+ Default: ``None``.
209
+ """
210
+
211
+ serial_type: CertType = Field(
212
+ default=CertType.CA,
213
+ description="Certificate category encoded into the serial number.",
214
+ )
215
+ is_client_cert: bool = Field(
216
+ default=False,
217
+ description="Add ClientAuth to Extended Key Usage when True.",
218
+ )
219
+ is_server_cert: bool = Field(
220
+ default=True,
221
+ description=(
222
+ "Add ServerAuth to Extended Key Usage and include CN as a DNS SAN "
223
+ "when True (required for TLS server certificates per RFC 2818)."
224
+ ),
225
+ )
226
+ san_dns: list[str] | None = Field(
227
+ default=None,
228
+ description="Additional DNS names for the Subject Alternative Name extension.",
229
+ )
230
+ san_ip: list[IPvAnyAddress] | None = Field(
231
+ default=None,
232
+ description="IP addresses (IPv4 or IPv6) for the Subject Alternative Name extension.",
233
+ )
234
+ email: EmailStr | None = Field(
235
+ default=None,
236
+ description="Optional email address added as an emailAddress Subject attribute.",
237
+ )
238
+ name: str | None = Field(
239
+ default=None,
240
+ description=(
241
+ "Explicit output file basename for storage artefacts. "
242
+ "Excluded from model_dump calls to the factory. "
243
+ "None causes the storage layer to derive the name from common_name."
244
+ ),
245
+ )
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Read-only metadata extracted from an existing CA certificate
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ class CertificateInfo(BaseModel):
254
+ """
255
+ Structured metadata extracted from the Subject field of a CA certificate.
256
+
257
+ Populated by ``CAFileLoader._extract_info`` and made available via the
258
+ ``ICALoader.base_info`` property. ``CertificateFactory`` uses these
259
+ values to populate the Subject of every end-entity certificate it issues,
260
+ ensuring that all leaf certificates share the same organisational identity
261
+ as the signing CA.
262
+
263
+ All fields are optional (``None``) because not every CA certificate
264
+ includes all Subject attributes.
265
+
266
+ Attributes
267
+ ----------
268
+ organization : str | None
269
+ Organization (O) field. Default: ``"My company"``.
270
+ organizational_unit : str | None
271
+ Organizational Unit (OU) field. Default: ``"server"``.
272
+ country : str | None
273
+ Two-letter ISO 3166-1 alpha-2 country code (C field).
274
+ Default: ``"UA"``.
275
+ state : str | None
276
+ State or Province (ST) field. Default: ``None``.
277
+ locality : str | None
278
+ Locality or City (L) field. Default: ``None``.
279
+ """
280
+
281
+ organization: str | None = Field(
282
+ default="My company",
283
+ description="Organization (O) extracted from the CA certificate Subject.",
284
+ )
285
+ organizational_unit: str | None = Field(
286
+ default="server",
287
+ description="Organizational Unit (OU) extracted from the CA certificate Subject.",
288
+ )
289
+ country: str | None = Field(
290
+ default="UA",
291
+ description="Country (C) extracted from the CA certificate Subject.",
292
+ )
293
+ state: str | None = Field(
294
+ default=None,
295
+ description="State or Province (ST) extracted from the CA certificate Subject.",
296
+ )
297
+ locality: str | None = Field(
298
+ default=None,
299
+ description="Locality / City (L) extracted from the CA certificate Subject.",
300
+ )
tiny_ca/settings.py ADDED
@@ -0,0 +1,9 @@
1
+ from logging import DEBUG, Formatter, StreamHandler, getLogger
2
+
3
+ DEFAULT_LOGGER = getLogger("TinyCA")
4
+ DEFAULT_LOGGER.setLevel(DEBUG)
5
+ _console_handler = StreamHandler()
6
+ _formatter = Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
7
+ _console_handler.setFormatter(_formatter)
8
+ DEFAULT_LOGGER.addHandler(_console_handler)
9
+ DT_STR_FORMAT = "%Y-%m-%d %H:%M:%S"
@@ -0,0 +1,4 @@
1
+ from .base_storage import BaseStorage
2
+ from .local_storage import LocalStorage
3
+
4
+ __all__ = ["BaseStorage", "LocalStorage"]
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Coroutine
5
+ from logging import Logger
6
+ from pathlib import Path
7
+ import shutil
8
+ from typing import Any
9
+ from uuid import uuid4
10
+ import warnings
11
+
12
+ import aiofiles
13
+ import cryptography
14
+ from cryptography import x509
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+
18
+ from tiny_ca.exc import FileAlreadyExists
19
+ from tiny_ca.settings import DEFAULT_LOGGER
20
+
21
+ from .base_storage import BaseStorage
22
+ from .const import CryptoObject
23
+ from .local_storage import LocalStorage, _CertSerializer
24
+
25
+
26
+ class AsyncLocalStorage(LocalStorage):
27
+ async def save_certificate( # type: ignore[override]
28
+ self,
29
+ cert: CryptoObject,
30
+ file_name: str,
31
+ cert_path: str | Path | None = None,
32
+ uuid_str: str | None = None,
33
+ encoding: serialization.Encoding | None = None,
34
+ private_format: serialization.PrivateFormat | None = None,
35
+ public_format: serialization.PublicFormat | None = None,
36
+ encryption_algorithm: serialization.KeySerializationEncryption | None = None,
37
+ is_add_uuid: bool = True,
38
+ is_overwrite: bool = False,
39
+ ) -> tuple[Path, str | None]:
40
+ """
41
+ Serialise *cert* and write the result to the local filesystem.
42
+
43
+ Assembles the output path as::
44
+
45
+ <base_folder> / [cert_path/] / [<uuid>/] / <file_name><ext>
46
+
47
+ Where *ext* is determined automatically from the type of *cert*.
48
+
49
+ Parameters
50
+ ----------
51
+ cert : CryptoObject
52
+ Cryptographic object to serialise and persist.
53
+ file_name : str
54
+ Base filename without extension (e.g. ``"ca"``, ``"nginx"``).
55
+ cert_path : str | Path | None
56
+ Optional sub-directory appended after *base_folder*.
57
+ uuid_str : str | None
58
+ Reuse an existing UUID directory by passing the value returned by
59
+ a previous ``save_certificate`` call. ``None`` auto-generates a
60
+ new UUID. Ignored when *is_add_uuid* is ``False``.
61
+ encoding : serialization.Encoding | None
62
+ Encoding override. ``None`` uses *base_encoding*.
63
+ private_format : serialization.PrivateFormat | None
64
+ Private-key format override. ``None`` uses *base_private_format*.
65
+ public_format : serialization.PublicFormat | None
66
+ Public-key format override. ``None`` uses *base_public_format*.
67
+ encryption_algorithm : serialization.KeySerializationEncryption | None
68
+ Private-key encryption override. ``None`` uses
69
+ *base_encryption_algorithm*.
70
+ is_add_uuid : bool
71
+ When ``True`` (default), a UUID subdirectory is inserted.
72
+ Set to ``False`` for singleton files such as CRL that are
73
+ regenerated in-place.
74
+ is_overwrite : bool
75
+ When ``True``, silently replace an existing file.
76
+ When ``False`` (default), raise ``FileAlreadyExists``.
77
+
78
+ Returns
79
+ -------
80
+ tuple[Path, str | None]
81
+ ``(absolute_path_to_written_file, uuid_used)``.
82
+ *uuid_used* is ``None`` when *is_add_uuid* is ``False``.
83
+
84
+ Raises
85
+ ------
86
+ FileAlreadyExists
87
+ If the computed target path already exists and *is_overwrite*
88
+ is ``False``.
89
+ TypeError
90
+ If *cert* is not a supported cryptographic type.
91
+ """
92
+ output_dir, effective_uuid = self._resolve_output_dir(
93
+ cert_path=cert_path,
94
+ uuid_str=uuid_str,
95
+ is_add_uuid=is_add_uuid,
96
+ )
97
+
98
+ cert_data, extension = await asyncio.to_thread(
99
+ _CertSerializer.serialise,
100
+ cert=cert,
101
+ encoding=encoding or self._base_encoding,
102
+ private_format=private_format or self._base_private_format,
103
+ public_format=public_format or self._base_public_format,
104
+ encryption_algorithm=encryption_algorithm
105
+ or self._base_encryption_algorithm,
106
+ )
107
+
108
+ output_path = output_dir / f"{file_name}{extension}"
109
+ await self._write_file(
110
+ path=output_path, data=cert_data, is_overwrite=is_overwrite
111
+ )
112
+
113
+ self._logger.debug(
114
+ "Saved %s → %s (uuid=%s)", type(cert).__name__, output_path, effective_uuid
115
+ )
116
+ return output_path, effective_uuid
117
+
118
+ async def _write_file( # type: ignore[override]
119
+ self, path: Path, data: bytes, is_overwrite: bool
120
+ ) -> None:
121
+ """
122
+ Write *data* to *path*, creating any missing parent directories.
123
+
124
+ Parent directories are created with ``mkdir(parents=True,
125
+ exist_ok=True)`` so the caller never needs to pre-create the
126
+ directory tree manually.
127
+
128
+ Parameters
129
+ ----------
130
+ path : Path
131
+ Absolute target path for the output file.
132
+ data : bytes
133
+ Raw serialised bytes to write.
134
+ is_overwrite : bool
135
+ When ``False``, raise ``FileAlreadyExists`` if *path* already
136
+ exists. When ``True``, the existing file is silently replaced.
137
+
138
+ Raises
139
+ ------
140
+ FileAlreadyExists
141
+ If *path* already exists on disk and *is_overwrite* is ``False``.
142
+ """
143
+ if path.exists() and not is_overwrite:
144
+ raise FileAlreadyExists(path_save_cert=path)
145
+
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+
148
+ async with aiofiles.open(path, "wb") as fh:
149
+ await fh.write(data)
150
+
151
+ self._logger.debug("Written %d bytes → %s", len(data), path)
152
+
153
+ async def delete_certificate_folder( # type: ignore[override]
154
+ self,
155
+ uuid_str: str,
156
+ cert_path: str | Path | None = None,
157
+ ) -> bool:
158
+ """
159
+ Recursively remove the directory identified by *uuid_str*.
160
+
161
+ The target path is resolved as::
162
+
163
+ <base_folder> / [cert_path/] / <uuid_str>
164
+
165
+ The operation is idempotent: if the directory does not exist a
166
+ ``UserWarning`` is emitted and ``True`` is returned (no action needed).
167
+ If the path exists but is a regular file rather than a directory, a
168
+ ``UserWarning`` is emitted and ``True`` is returned (not our directory).
169
+ Only a genuine ``OSError`` during ``shutil.rmtree`` causes ``False``.
170
+
171
+ Parameters
172
+ ----------
173
+ uuid_str : str
174
+ UUID sub-directory name to remove.
175
+ cert_path : str | Path | None
176
+ Optional sub-path under *base_folder* containing *uuid_str*.
177
+
178
+ Returns
179
+ -------
180
+ bool
181
+ ``True`` — directory removed, or path was already absent.
182
+ ``False`` — ``OSError`` occurred; check logs for details.
183
+
184
+ Warns
185
+ -----
186
+ UserWarning
187
+ If the target path does not exist or is not a directory.
188
+ """
189
+ target = self._base_folder
190
+ if cert_path:
191
+ target = target / cert_path
192
+ target = target / uuid_str
193
+
194
+ if not target.exists():
195
+ warnings.warn(
196
+ f"Certificate folder not deleted — path does not exist: {target}",
197
+ UserWarning,
198
+ stacklevel=2,
199
+ )
200
+ return True
201
+
202
+ if not target.is_dir():
203
+ warnings.warn(
204
+ f"Certificate folder not deleted — path is not a directory: {target}",
205
+ UserWarning,
206
+ stacklevel=2,
207
+ )
208
+ return True
209
+
210
+ try:
211
+ await asyncio.to_thread(shutil.rmtree, path=target)
212
+ self._logger.info("Deleted certificate folder: %s", target)
213
+ return True
214
+ except OSError as exc:
215
+ self._logger.error(
216
+ "Failed to delete certificate folder %s: %s", target, exc
217
+ )
218
+ return False