tiny-ca 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (68) hide show
  1. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/LICENSE +1 -1
  2. {tiny_ca-0.2.0/tiny_ca.egg-info → tiny_ca-0.3.0}/PKG-INFO +125 -26
  3. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/README.md +122 -21
  4. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/pyproject.toml +44 -22
  5. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_afile_loader.py +23 -4
  6. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_async_db_handler.py +16 -14
  7. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_async_lifecycle_manager.py +1 -1
  8. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_async_local_storage.py +3 -3
  9. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_database_manager.py +6 -2
  10. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_factory.py +6 -4
  11. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_file_loader.py +22 -6
  12. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_life_time.py +17 -29
  13. tiny_ca-0.3.0/tests/test_review_regressions.py +851 -0
  14. tiny_ca-0.3.0/tests/test_review_regressions_async.py +269 -0
  15. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_serial.py +7 -19
  16. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_sync_db_manager.py +20 -18
  17. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/__init__.py +3 -3
  18. tiny_ca-0.3.0/tiny_ca/ca_factory/factory.py +1223 -0
  19. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/afile_loader.py +10 -7
  20. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/file_loader.py +13 -10
  21. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/life_time.py +4 -2
  22. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/const.py +28 -1
  23. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/async_db_manager.py +93 -212
  24. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/sync_db_manager.py +87 -171
  25. tiny_ca-0.3.0/tiny_ca/db/utils.py +67 -0
  26. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/exc.py +33 -0
  27. tiny_ca-0.3.0/tiny_ca/managers/async_lifecycle_manager.py +652 -0
  28. tiny_ca-0.3.0/tiny_ca/managers/sync_lifecycle_manager.py +945 -0
  29. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/models/certificate.py +42 -5
  30. tiny_ca-0.3.0/tiny_ca/settings.py +15 -0
  31. tiny_ca-0.3.0/tiny_ca/storage/async_local_storage.py +118 -0
  32. tiny_ca-0.3.0/tiny_ca/storage/const.py +40 -0
  33. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/storage/local_storage.py +227 -201
  34. tiny_ca-0.3.0/tiny_ca/utils/keys.py +101 -0
  35. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/utils/serial_generator.py +40 -21
  36. {tiny_ca-0.2.0 → tiny_ca-0.3.0/tiny_ca.egg-info}/PKG-INFO +125 -26
  37. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca.egg-info/SOURCES.txt +4 -0
  38. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca.egg-info/requires.txt +1 -3
  39. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca.egg-info/top_level.txt +1 -0
  40. tiny_ca-0.2.0/tiny_ca/ca_factory/factory.py +0 -1307
  41. tiny_ca-0.2.0/tiny_ca/managers/async_lifecycle_manager.py +0 -871
  42. tiny_ca-0.2.0/tiny_ca/managers/sync_lifecycle_manager.py +0 -885
  43. tiny_ca-0.2.0/tiny_ca/settings.py +0 -9
  44. tiny_ca-0.2.0/tiny_ca/storage/async_local_storage.py +0 -218
  45. tiny_ca-0.2.0/tiny_ca/storage/const.py +0 -39
  46. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/docs/source/conf.py +0 -0
  47. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/setup.cfg +0 -0
  48. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_base_db.py +0 -0
  49. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_base_storage.py +0 -0
  50. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_db_const.py +0 -0
  51. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_local_storage.py +0 -0
  52. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_models.py +0 -0
  53. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_serial_generator.py +0 -0
  54. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_storage_const.py +0 -0
  55. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tests/test_sync_lifecycle_manager.py +0 -0
  56. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/__init__.py +0 -0
  57. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/__init__.py +0 -0
  58. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/serial.py +0 -0
  59. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/__init__.py +0 -0
  60. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/base_db.py +0 -0
  61. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/const.py +0 -0
  62. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/db/models.py +0 -0
  63. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/managers/__init__.py +0 -0
  64. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/models/__init__.py +0 -0
  65. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/storage/__init__.py +0 -0
  66. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/storage/base_storage.py +0 -0
  67. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca/utils/__init__.py +0 -0
  68. {tiny_ca-0.2.0 → tiny_ca-0.3.0}/tiny_ca.egg-info/dependency_links.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Denys (Shchusia)
3
+ Copyright (c) 2026 Denis Shchutskyi (Shchusia)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiny-ca
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A lightweight, 100%-tested Python library for managing the full lifecycle of X.509 certificates — issuance, revocation, rotation, CRL generation, PKCS#12 export, and more.
5
5
  Author-email: Denis Shchutskyi <denisshchutskyi@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Repository, https://github.com/Shchusia/tiny_ca
8
8
  Project-URL: Documentation, https://shchusia.github.io/tiny_ca/
9
9
  Project-URL: Bug Tracker, https://github.com/Shchusia/tiny_ca/issues
10
- Project-URL: Changelog, https://github.com/Shchusia/tiny_ca/blob/main/CHANGELOG.md
10
+ Project-URL: Changelog, https://github.com/Shchusia/tiny_ca/blob/master/CHANGELOG.md
11
11
  Keywords: pki,x509,certificate,ca,tls,ssl,cryptography,crl,pkcs12
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3
@@ -23,11 +23,9 @@ Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: cryptography>=46.0.0
25
25
  Requires-Dist: pydantic[email]>=2.12.0
26
- Requires-Dist: sqlalchemy>=2.0.0
26
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.0
27
27
  Provides-Extra: async
28
- Requires-Dist: aiofiles>=25.1.0; extra == "async"
29
28
  Requires-Dist: aiosqlite>=0.22.0; extra == "async"
30
- Requires-Dist: types-aiofiles>=25.1.0; extra == "async"
31
29
  Provides-Extra: postgres
32
30
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
33
31
  Provides-Extra: postgres-async
@@ -42,8 +40,9 @@ Dynamic: license-file
42
40
 
43
41
  # tiny_ca
44
42
 
45
- [![Python](https://img.shields.io/badge/Python-%3E%3D3.11-informational)](https://github.com/Shchusia/tiny_ca)
43
+ [![Python](https://img.shields.io/pypi/pyversions/tiny-ca)](https://pypi.org/project/tiny-ca/)
46
44
  [![PyPI](https://img.shields.io/pypi/v/tiny-ca?color=blue)](https://pypi.org/project/tiny-ca/)
45
+
47
46
  [![Coverage Status](https://coveralls.io/repos/github/Shchusia/tiny_ca/badge.svg?branch=feature/docs)](https://coveralls.io/github/Shchusia/tiny_ca?branch=feature/docs)
48
47
  [![Docs](https://img.shields.io/badge/Docs-passing-green)](https://shchusia.github.io/tiny_ca/)
49
48
  [![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE)
@@ -52,6 +51,43 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
52
51
 
53
52
  ---
54
53
 
54
+ ## REST API
55
+
56
+ > Looking for a ready-to-use HTTP server built on **tiny-ca**?
57
+ > Check out **[tiny-ca-gateway](https://github.com/Shchusia/tiny-ca-gateway)** — a framework-agnostic REST API adapter that exposes all 22 certificate lifecycle endpoints with no extra code.
58
+
59
+ | | |
60
+ |---|---|
61
+ | **GitHub** | [github.com/Shchusia/tiny-ca-gateway](https://github.com/Shchusia/tiny-ca-gateway) |
62
+ | **Integration guide** | [tiny-ca-gateway/blob/master/README.md](https://github.com/Shchusia/tiny-ca-gateway/blob/master/README.md) |
63
+ | **Supported frameworks** | FastAPI · Flask · aiohttp · Django Ninja |
64
+
65
+ ```bash
66
+ pip install "tiny-ca-gateway[fastapi]" # FastAPI + Uvicorn
67
+ pip install "tiny-ca-gateway[flask]" # Flask + Gunicorn
68
+ pip install "tiny-ca-gateway[aiohttp]" # aiohttp
69
+ pip install "tiny-ca-gateway[django]" # Django + Django Ninja
70
+ ```
71
+
72
+ ```python
73
+ # FastAPI — all 22 CA endpoints in 5 lines
74
+ from fastapi import FastAPI
75
+ from contextlib import asynccontextmanager
76
+ from tiny_ca_gateway.fastapi.lifespan.manager import FastAPILifespanManager
77
+ from tiny_ca_gateway.fastapi.api.v1.ca_routes import router
78
+
79
+ @asynccontextmanager
80
+ async def lifespan(app: FastAPI):
81
+ await FastAPILifespanManager(common_name="My CA").on_startup()
82
+ yield
83
+
84
+ app = FastAPI(lifespan=lifespan)
85
+ app.include_router(router, prefix="/api/v1")
86
+ # → Swagger UI at http://localhost:8000/docs
87
+ ```
88
+
89
+ ---
90
+
55
91
  ## Table of Contents
56
92
 
57
93
  - [Features](#features)
@@ -80,14 +116,16 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
80
116
  |---|---|
81
117
  | **CA bootstrap** | Self-signed root CA and intermediate (sub) CA with configurable `path_length` |
82
118
  | **Issuance** | Leaf certificates with SANs (DNS + IP), EKU (Server/Client Auth), email attribute |
83
- | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete |
84
- | **CRL** | Build, sign, and verify Certificate Revocation Lists with configurable validity |
119
+ | **Key algorithms** | RSA (2048+), ECDSA P-256 / P-384, Ed25519 — for the CA and for leaves |
120
+ | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete — every CA-signed certificate is registered, so it can always be revoked |
121
+ | **CRL** | RFC 5280 CRLs with `CRLNumber`, AKI and per-entry reason codes; optional CRL Distribution Points / AIA in issued certificates |
122
+ | **Validation** | Direct (`validate_cert`) or through intermediates (`validate_chain`, with `BasicConstraints` / `pathLen` checks); registry check is fail-closed |
85
123
  | **Inspection** | Structured `CertificateDetails` snapshot from any `x509.Certificate` — fully serialisable |
86
124
  | **Export** | PKCS#12 (`.p12`/`.pfx`) bundles with full CA chain |
87
125
  | **Co-signing** | Re-sign third-party certificates under your CA, preserving Subject and extensions |
88
126
  | **Chain** | Build `[leaf, ca]` fullchain ready for nginx, Apache, or Envoy |
89
127
  | **Monitoring** | List certs with filters, find expiring-soon, bulk-expire stale records |
90
- | **Storage** | `LocalStorage` (sync) and `AsyncLocalStorage` (async, aiofiles) with UUID-based isolation |
128
+ | **Storage** | `LocalStorage` (sync) and `AsyncLocalStorage` (async) with UUID-based isolation, `0600` private keys and path-traversal protection |
91
129
  | **Database** | `SyncDBHandler` (SQLAlchemy) and `AsyncDBHandler` (aiosqlite) — SQLite, PostgreSQL, MySQL |
92
130
  | **API parity** | `CertLifecycleManager` and `AsyncCertLifecycleManager` are feature-identical |
93
131
  | **Serial numbers** | `SerialWithEncoding` — 160-bit RFC 5280-compliant, type-prefixed, name-encoded |
@@ -99,7 +137,7 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
99
137
 
100
138
  - **Python** 3.11 or higher
101
139
  - **Core:** `cryptography >= 46`, `sqlalchemy >= 2`, `pydantic >= 2`
102
- - **Async extras:** `aiofiles`, `aiosqlite`
140
+ - **Async extras:** `aiosqlite`
103
141
  - **PostgreSQL:** `psycopg2-binary` (sync) / `asyncpg` (async)
104
142
  - **MySQL:** `pymysql` (sync) / `aiomysql` (async)
105
143
 
@@ -120,7 +158,7 @@ CertLifecycleManager / AsyncCertLifecycleManager ← application entry-point
120
158
  │
121
159
  ├── BaseStorage (ABC)
122
160
  │ ├── LocalStorage ← sync filesystem (UUID-isolated)
123
- │ └── AsyncLocalStorage ← async filesystem (aiofiles)
161
+ │ └── AsyncLocalStorage ← async filesystem (worker threads)
124
162
  │
125
163
  └── BaseDB (ABC)
126
164
  ├── SyncDBHandler ← SQLAlchemy sync
@@ -164,7 +202,13 @@ from tiny_ca.storage.local_storage import LocalStorage
164
202
  from tiny_ca.db.sync_db_manager import SyncDBHandler
165
203
  from tiny_ca.models.certificate import CAConfig
166
204
 
167
- storage = LocalStorage(base_folder="./pki")
205
+ from cryptography.hazmat.primitives.serialization import BestAvailableEncryption
206
+
207
+ # Private keys are always written with mode 0600; encrypt them at rest too.
208
+ storage = LocalStorage(
209
+ base_folder="./pki",
210
+ base_encryption_algorithm=BestAvailableEncryption(b"key-passphrase"),
211
+ )
168
212
  db = SyncDBHandler(db_url="sqlite:///pki.db")
169
213
  mgr = CertLifecycleManager(storage=storage, db_handler=db)
170
214
 
@@ -180,10 +224,19 @@ Attach the factory so the manager can issue certificates:
180
224
  from tiny_ca.ca_factory.utils.file_loader import CAFileLoader
181
225
  from tiny_ca.ca_factory.factory import CertificateFactory
182
226
 
183
- loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path)
184
- mgr.factory = CertificateFactory(loader)
227
+ loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path,
228
+ ca_key_password=b"key-passphrase")
229
+ mgr.factory = CertificateFactory(
230
+ loader,
231
+ # Optional: tell relying parties where to find the CRL / CA certificate.
232
+ crl_distribution_points=["http://pki.example/crl.pem"],
233
+ ca_issuers_urls=["http://pki.example/ca.pem"],
234
+ )
185
235
  ```
186
236
 
237
+ `CAConfig` / `ClientConfig` accept `key_algorithm=KeyAlgorithm.EC_P256`
238
+ (also `EC_P384`, `ED25519`, default `RSA`) — see [Configuration Models](#configuration-models).
239
+
187
240
  ### 2. Issue a Leaf Certificate
188
241
 
189
242
  ```python
@@ -207,7 +260,9 @@ cert, key, csr = mgr.issue_certificate(
207
260
  ### 3. Renew a Certificate (same key)
208
261
 
209
262
  Keeps the existing public key — only validity window and serial number change.
210
- Use when the key has **not** been compromised.
263
+ Use when the key has **not** been compromised. The renewed certificate is
264
+ stored and registered (so it can be revoked); the original is revoked with
265
+ reason `superseded`. The private key stays in the original folder.
211
266
 
212
267
  ```python
213
268
  renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
@@ -215,7 +270,9 @@ renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
215
270
 
216
271
  ### 4. Rotate a Certificate (new key)
217
272
 
218
- Atomically revokes the old cert and issues a replacement with a fresh key pair.
273
+ Issues a replacement with a fresh key pair, then revokes the old cert
274
+ (`superseded`). If the old cert cannot be revoked, the replacement is rolled
275
+ back and `CertRevocationError` is raised.
219
276
 
220
277
  ```python
221
278
  new_cert, new_key, new_csr = mgr.rotate_certificate(
@@ -240,6 +297,10 @@ crl = mgr.generate_crl(days_valid=7) # written to <base_folder>/crl.pem
240
297
  mgr.verify_crl(crl) # raises ValidationCertError on failure
241
298
  ```
242
299
 
300
+ Revoked certificates stay on the CRL until they expire. If the database
301
+ cannot be read, `generate_crl` raises `DBOperationError` and the previous
302
+ `crl.pem` is left untouched — an empty CRL is never published.
303
+
243
304
  ### 7. Issue an Intermediate CA
244
305
 
245
306
  ```python
@@ -248,6 +309,10 @@ sub_ca_cert, sub_ca_key = mgr.issue_intermediate_ca(
248
309
  path_length=0, organization="ACME Corp", country="UA",
249
310
  cert_path="intermediate",
250
311
  )
312
+
313
+ # The intermediate is registered (revocable via the root's CRL). Verify a
314
+ # leaf issued by it through the chain:
315
+ mgr.verify_certificate(leaf_cert, intermediates=[sub_ca_cert])
251
316
  ```
252
317
 
253
318
  ### 8. Export PKCS#12
@@ -265,6 +330,7 @@ with open("nginx.p12", "wb") as f:
265
330
  from cryptography import x509
266
331
  third_party = x509.load_pem_x509_certificate(open("partner.pem", "rb").read())
267
332
  cosigned = mgr.cosign_certificate(cert=third_party, days_valid=365)
333
+ # stored + registered like any other certificate issued by this CA
268
334
  ```
269
335
 
270
336
  ### 10. Inspect a Certificate
@@ -349,7 +415,8 @@ python examples/acomplete_example.py
349
415
  | `common_name` | `str` | `"Internal CA"` | CA Common Name (CN) |
350
416
  | `organization` | `str` | `"My Company"` | Organization (O) |
351
417
  | `country` | `str` | `"UA"` | Two-letter ISO 3166-1 alpha-2 code |
352
- | `key_size` | `int` | `2048` | RSA key length in bits |
418
+ | `key_size` | `int` | `2048` | RSA key length in bits (≥ 2048; ignored for EC / Ed25519) |
419
+ | `key_algorithm` | `KeyAlgorithm` | `RSA` | `RSA`, `EC_P256`, `EC_P384` or `ED25519` |
353
420
  | `days_valid` | `int` | `3650` | Validity period in days |
354
421
  | `valid_from` | `datetime \| None` | `None` | Explicit UTC start; `None` = now |
355
422
 
@@ -359,14 +426,15 @@ python examples/acomplete_example.py
359
426
  |---|---|---|---|
360
427
  | `common_name` | `str` | — | Certificate CN |
361
428
  | `serial_type` | `CertType` | `SERVICE` | Certificate category |
362
- | `key_size` | `int` | `2048` | RSA key length |
429
+ | `key_size` | `int` | `2048` | RSA key length (≥ 2048; ignored for EC / Ed25519) |
430
+ | `key_algorithm` | `KeyAlgorithm` | `RSA` | `RSA`, `EC_P256`, `EC_P384` or `ED25519` |
363
431
  | `days_valid` | `int` | `3650` | Validity period |
364
432
  | `email` | `EmailStr \| None` | `None` | `emailAddress` Subject attribute |
365
433
  | `is_server_cert` | `bool` | `True` | Adds ServerAuth EKU + CN as DNS SAN |
366
434
  | `is_client_cert` | `bool` | `False` | Adds ClientAuth EKU |
367
435
  | `san_dns` | `list[str] \| None` | `None` | Extra DNS Subject Alternative Names |
368
436
  | `san_ip` | `list[str] \| None` | `None` | IP address SANs |
369
- | `name` | `str \| None` | `None` | Override output file base name |
437
+ | `name` | `str \| None` | `None` | Override output file base name (letters, digits, `.`, `-`, `_`; no leading dot) |
370
438
 
371
439
  ### `CertType`
372
440
 
@@ -393,14 +461,23 @@ storage = LocalStorage(base_folder="./pki")
393
461
  ./pki/
394
462
  └── [cert_path/]
395
463
  └── <uuid>/
396
- ├── nginx.pem ← x509.Certificate
397
- ├── nginx.key ← RSA private key
398
- └── nginx.csr ← CSR
464
+ ├── nginx.pem ← x509.Certificate (0644)
465
+ ├── nginx.key ← private key (0600)
466
+ └── nginx.csr ← CSR (0644)
399
467
  ```
400
468
 
469
+ - Private keys are written with mode `0600`. They are **unencrypted unless**
470
+ `base_encryption_algorithm=BestAvailableEncryption(password)` is passed — a
471
+ warning is logged once per storage instance otherwise.
472
+ - `file_name`, `cert_path` and `uuid_str` are validated: absolute paths, `..`
473
+ segments and backslashes raise `InvalidPathError`.
474
+ - Without `is_overwrite=True` files are created with `O_EXCL`, so an existing
475
+ file is never replaced.
476
+
401
477
  ### `AsyncLocalStorage` (async)
402
478
 
403
- Drop-in async replacement — same constructor, same layout, all I/O via `aiofiles`.
479
+ Drop-in async replacement — same constructor, same layout and the same
480
+ security properties; blocking I/O runs in worker threads.
404
481
 
405
482
  ---
406
483
 
@@ -431,6 +508,13 @@ await db._db.init_db()
431
508
  | `delete_by_uuid(uuid)` | Hard-delete a record |
432
509
  | `update_status_expired()` | Bulk-mark stale VALID records as EXPIRED |
433
510
 
511
+ Read methods (and `update_status_expired`) raise `DBOperationError` on any
512
+ database failure instead of returning `None` / an empty list — a CA must fail
513
+ closed. `register_cert_in_db` returns `False` on failure; the managers turn
514
+ that into `DBOperationError` and remove the artefacts they wrote. Datetimes
515
+ are stored as naive UTC (`DateTime` columns without time zone), which keeps
516
+ PostgreSQL / asyncpg happy.
517
+
434
518
  ---
435
519
 
436
520
  ## Serial Number Encoding
@@ -440,7 +524,7 @@ await db._db.init_db()
440
524
  ```
441
525
  ┌──────────────┬──────────────────────┬────────────────────┐
442
526
  │ 16-bit type │ 80-bit name │ 64-bit random │
443
- │ prefix │ (up to 10 chars) │ (UUID fragment) │
527
+ │ prefix │ (up to 10 chars) │ (secrets.randbits)│
444
528
  └──────────────┴──────────────────────┴────────────────────┘
445
529
  ```
446
530
 
@@ -453,6 +537,10 @@ cert_type, name = SerialWithEncoding.parse(serial)
453
537
  # cert_type == CertType.SERVICE, name == "nginx"
454
538
  ```
455
539
 
540
+ Non-ASCII characters are stored as `?`, so a Cyrillic CN can never overflow
541
+ into the type prefix. The top bit is always zero, so the DER encoding fits the
542
+ 20-octet limit of RFC 5280.
543
+
456
544
  ---
457
545
 
458
546
  ## Error Reference
@@ -469,6 +557,9 @@ cert_type, name = SerialWithEncoding.parse(serial)
469
557
  | `IsNotFile` | CA PEM path is a directory | Provide a file, not a directory |
470
558
  | `WrongType` | Unsupported file extension | Use `.pem`, `.key`, or `.csr` |
471
559
  | `ErrorLoadCert` | PEM deserialisation failed | Check file format and integrity |
560
+ | `DBOperationError` | Registry could not be read / written | Check the database; nothing was trusted or published |
561
+ | `CertRevocationError` | Rotation/renewal could not revoke the old cert | Retry; the replacement was rolled back |
562
+ | `InvalidPathError` | Storage path would escape `base_folder` | Use plain names / relative sub-paths |
472
563
 
473
564
  ---
474
565
 
@@ -478,7 +569,16 @@ cert_type, name = SerialWithEncoding.parse(serial)
478
569
  Yes — load it with `CAFileLoader` or `AsyncCAFileLoader`.
479
570
 
480
571
  **What's the difference between `renew` and `rotate`?**
481
- `renew` keeps the key pair and extends validity. `rotate` generates a new key and revokes the old cert.
572
+ `renew` keeps the key pair and extends validity. `rotate` generates a new key. Both revoke the old cert with reason `superseded`.
573
+
574
+ **Why does `verify_certificate` reject a certificate signed by my CA?**
575
+ Since 0.3.0 a certificate issued directly by the CA must also be present in the
576
+ registry — otherwise its revocation status is unknown. Certificates created
577
+ with older versions via `renew` / `cosign` were not registered; re-issue them.
578
+
579
+ **How do I see the library's logs?**
580
+ `tiny_ca` only attaches a `NullHandler`; configure the `"TinyCA"` logger in your
581
+ application, e.g. `logging.getLogger("TinyCA").setLevel(logging.INFO)`.
482
582
 
483
583
  **How do I schedule CRL regeneration?**
484
584
  Use APScheduler or any cron solution:
@@ -586,4 +686,3 @@ Do **not** open public issues for security vulnerabilities. Email the maintainer
586
686
  | SQLAlchemy | MIT |
587
687
  | Pydantic | MIT |
588
688
  | aiosqlite | MIT |
589
- | aiofiles | Apache 2.0 |
@@ -1,7 +1,8 @@
1
1
  # tiny_ca
2
2
 
3
- [![Python](https://img.shields.io/badge/Python-%3E%3D3.11-informational)](https://github.com/Shchusia/tiny_ca)
3
+ [![Python](https://img.shields.io/pypi/pyversions/tiny-ca)](https://pypi.org/project/tiny-ca/)
4
4
  [![PyPI](https://img.shields.io/pypi/v/tiny-ca?color=blue)](https://pypi.org/project/tiny-ca/)
5
+
5
6
  [![Coverage Status](https://coveralls.io/repos/github/Shchusia/tiny_ca/badge.svg?branch=feature/docs)](https://coveralls.io/github/Shchusia/tiny_ca?branch=feature/docs)
6
7
  [![Docs](https://img.shields.io/badge/Docs-passing-green)](https://shchusia.github.io/tiny_ca/)
7
8
  [![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE)
@@ -10,6 +11,43 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
10
11
 
11
12
  ---
12
13
 
14
+ ## REST API
15
+
16
+ > Looking for a ready-to-use HTTP server built on **tiny-ca**?
17
+ > Check out **[tiny-ca-gateway](https://github.com/Shchusia/tiny-ca-gateway)** — a framework-agnostic REST API adapter that exposes all 22 certificate lifecycle endpoints with no extra code.
18
+
19
+ | | |
20
+ |---|---|
21
+ | **GitHub** | [github.com/Shchusia/tiny-ca-gateway](https://github.com/Shchusia/tiny-ca-gateway) |
22
+ | **Integration guide** | [tiny-ca-gateway/blob/master/README.md](https://github.com/Shchusia/tiny-ca-gateway/blob/master/README.md) |
23
+ | **Supported frameworks** | FastAPI · Flask · aiohttp · Django Ninja |
24
+
25
+ ```bash
26
+ pip install "tiny-ca-gateway[fastapi]" # FastAPI + Uvicorn
27
+ pip install "tiny-ca-gateway[flask]" # Flask + Gunicorn
28
+ pip install "tiny-ca-gateway[aiohttp]" # aiohttp
29
+ pip install "tiny-ca-gateway[django]" # Django + Django Ninja
30
+ ```
31
+
32
+ ```python
33
+ # FastAPI — all 22 CA endpoints in 5 lines
34
+ from fastapi import FastAPI
35
+ from contextlib import asynccontextmanager
36
+ from tiny_ca_gateway.fastapi.lifespan.manager import FastAPILifespanManager
37
+ from tiny_ca_gateway.fastapi.api.v1.ca_routes import router
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(app: FastAPI):
41
+ await FastAPILifespanManager(common_name="My CA").on_startup()
42
+ yield
43
+
44
+ app = FastAPI(lifespan=lifespan)
45
+ app.include_router(router, prefix="/api/v1")
46
+ # → Swagger UI at http://localhost:8000/docs
47
+ ```
48
+
49
+ ---
50
+
13
51
  ## Table of Contents
14
52
 
15
53
  - [Features](#features)
@@ -38,14 +76,16 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
38
76
  |---|---|
39
77
  | **CA bootstrap** | Self-signed root CA and intermediate (sub) CA with configurable `path_length` |
40
78
  | **Issuance** | Leaf certificates with SANs (DNS + IP), EKU (Server/Client Auth), email attribute |
41
- | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete |
42
- | **CRL** | Build, sign, and verify Certificate Revocation Lists with configurable validity |
79
+ | **Key algorithms** | RSA (2048+), ECDSA P-256 / P-384, Ed25519 — for the CA and for leaves |
80
+ | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete — every CA-signed certificate is registered, so it can always be revoked |
81
+ | **CRL** | RFC 5280 CRLs with `CRLNumber`, AKI and per-entry reason codes; optional CRL Distribution Points / AIA in issued certificates |
82
+ | **Validation** | Direct (`validate_cert`) or through intermediates (`validate_chain`, with `BasicConstraints` / `pathLen` checks); registry check is fail-closed |
43
83
  | **Inspection** | Structured `CertificateDetails` snapshot from any `x509.Certificate` — fully serialisable |
44
84
  | **Export** | PKCS#12 (`.p12`/`.pfx`) bundles with full CA chain |
45
85
  | **Co-signing** | Re-sign third-party certificates under your CA, preserving Subject and extensions |
46
86
  | **Chain** | Build `[leaf, ca]` fullchain ready for nginx, Apache, or Envoy |
47
87
  | **Monitoring** | List certs with filters, find expiring-soon, bulk-expire stale records |
48
- | **Storage** | `LocalStorage` (sync) and `AsyncLocalStorage` (async, aiofiles) with UUID-based isolation |
88
+ | **Storage** | `LocalStorage` (sync) and `AsyncLocalStorage` (async) with UUID-based isolation, `0600` private keys and path-traversal protection |
49
89
  | **Database** | `SyncDBHandler` (SQLAlchemy) and `AsyncDBHandler` (aiosqlite) — SQLite, PostgreSQL, MySQL |
50
90
  | **API parity** | `CertLifecycleManager` and `AsyncCertLifecycleManager` are feature-identical |
51
91
  | **Serial numbers** | `SerialWithEncoding` — 160-bit RFC 5280-compliant, type-prefixed, name-encoded |
@@ -57,7 +97,7 @@ A lightweight, **100%-tested** Python library for managing the full lifecycle of
57
97
 
58
98
  - **Python** 3.11 or higher
59
99
  - **Core:** `cryptography >= 46`, `sqlalchemy >= 2`, `pydantic >= 2`
60
- - **Async extras:** `aiofiles`, `aiosqlite`
100
+ - **Async extras:** `aiosqlite`
61
101
  - **PostgreSQL:** `psycopg2-binary` (sync) / `asyncpg` (async)
62
102
  - **MySQL:** `pymysql` (sync) / `aiomysql` (async)
63
103
 
@@ -78,7 +118,7 @@ CertLifecycleManager / AsyncCertLifecycleManager ← application entry-point
78
118
  │
79
119
  ├── BaseStorage (ABC)
80
120
  │ ├── LocalStorage ← sync filesystem (UUID-isolated)
81
- │ └── AsyncLocalStorage ← async filesystem (aiofiles)
121
+ │ └── AsyncLocalStorage ← async filesystem (worker threads)
82
122
  │
83
123
  └── BaseDB (ABC)
84
124
  ├── SyncDBHandler ← SQLAlchemy sync
@@ -122,7 +162,13 @@ from tiny_ca.storage.local_storage import LocalStorage
122
162
  from tiny_ca.db.sync_db_manager import SyncDBHandler
123
163
  from tiny_ca.models.certificate import CAConfig
124
164
 
125
- storage = LocalStorage(base_folder="./pki")
165
+ from cryptography.hazmat.primitives.serialization import BestAvailableEncryption
166
+
167
+ # Private keys are always written with mode 0600; encrypt them at rest too.
168
+ storage = LocalStorage(
169
+ base_folder="./pki",
170
+ base_encryption_algorithm=BestAvailableEncryption(b"key-passphrase"),
171
+ )
126
172
  db = SyncDBHandler(db_url="sqlite:///pki.db")
127
173
  mgr = CertLifecycleManager(storage=storage, db_handler=db)
128
174
 
@@ -138,10 +184,19 @@ Attach the factory so the manager can issue certificates:
138
184
  from tiny_ca.ca_factory.utils.file_loader import CAFileLoader
139
185
  from tiny_ca.ca_factory.factory import CertificateFactory
140
186
 
141
- loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path)
142
- mgr.factory = CertificateFactory(loader)
187
+ loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path,
188
+ ca_key_password=b"key-passphrase")
189
+ mgr.factory = CertificateFactory(
190
+ loader,
191
+ # Optional: tell relying parties where to find the CRL / CA certificate.
192
+ crl_distribution_points=["http://pki.example/crl.pem"],
193
+ ca_issuers_urls=["http://pki.example/ca.pem"],
194
+ )
143
195
  ```
144
196
 
197
+ `CAConfig` / `ClientConfig` accept `key_algorithm=KeyAlgorithm.EC_P256`
198
+ (also `EC_P384`, `ED25519`, default `RSA`) — see [Configuration Models](#configuration-models).
199
+
145
200
  ### 2. Issue a Leaf Certificate
146
201
 
147
202
  ```python
@@ -165,7 +220,9 @@ cert, key, csr = mgr.issue_certificate(
165
220
  ### 3. Renew a Certificate (same key)
166
221
 
167
222
  Keeps the existing public key — only validity window and serial number change.
168
- Use when the key has **not** been compromised.
223
+ Use when the key has **not** been compromised. The renewed certificate is
224
+ stored and registered (so it can be revoked); the original is revoked with
225
+ reason `superseded`. The private key stays in the original folder.
169
226
 
170
227
  ```python
171
228
  renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
@@ -173,7 +230,9 @@ renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
173
230
 
174
231
  ### 4. Rotate a Certificate (new key)
175
232
 
176
- Atomically revokes the old cert and issues a replacement with a fresh key pair.
233
+ Issues a replacement with a fresh key pair, then revokes the old cert
234
+ (`superseded`). If the old cert cannot be revoked, the replacement is rolled
235
+ back and `CertRevocationError` is raised.
177
236
 
178
237
  ```python
179
238
  new_cert, new_key, new_csr = mgr.rotate_certificate(
@@ -198,6 +257,10 @@ crl = mgr.generate_crl(days_valid=7) # written to <base_folder>/crl.pem
198
257
  mgr.verify_crl(crl) # raises ValidationCertError on failure
199
258
  ```
200
259
 
260
+ Revoked certificates stay on the CRL until they expire. If the database
261
+ cannot be read, `generate_crl` raises `DBOperationError` and the previous
262
+ `crl.pem` is left untouched — an empty CRL is never published.
263
+
201
264
  ### 7. Issue an Intermediate CA
202
265
 
203
266
  ```python
@@ -206,6 +269,10 @@ sub_ca_cert, sub_ca_key = mgr.issue_intermediate_ca(
206
269
  path_length=0, organization="ACME Corp", country="UA",
207
270
  cert_path="intermediate",
208
271
  )
272
+
273
+ # The intermediate is registered (revocable via the root's CRL). Verify a
274
+ # leaf issued by it through the chain:
275
+ mgr.verify_certificate(leaf_cert, intermediates=[sub_ca_cert])
209
276
  ```
210
277
 
211
278
  ### 8. Export PKCS#12
@@ -223,6 +290,7 @@ with open("nginx.p12", "wb") as f:
223
290
  from cryptography import x509
224
291
  third_party = x509.load_pem_x509_certificate(open("partner.pem", "rb").read())
225
292
  cosigned = mgr.cosign_certificate(cert=third_party, days_valid=365)
293
+ # stored + registered like any other certificate issued by this CA
226
294
  ```
227
295
 
228
296
  ### 10. Inspect a Certificate
@@ -307,7 +375,8 @@ python examples/acomplete_example.py
307
375
  | `common_name` | `str` | `"Internal CA"` | CA Common Name (CN) |
308
376
  | `organization` | `str` | `"My Company"` | Organization (O) |
309
377
  | `country` | `str` | `"UA"` | Two-letter ISO 3166-1 alpha-2 code |
310
- | `key_size` | `int` | `2048` | RSA key length in bits |
378
+ | `key_size` | `int` | `2048` | RSA key length in bits (≥ 2048; ignored for EC / Ed25519) |
379
+ | `key_algorithm` | `KeyAlgorithm` | `RSA` | `RSA`, `EC_P256`, `EC_P384` or `ED25519` |
311
380
  | `days_valid` | `int` | `3650` | Validity period in days |
312
381
  | `valid_from` | `datetime \| None` | `None` | Explicit UTC start; `None` = now |
313
382
 
@@ -317,14 +386,15 @@ python examples/acomplete_example.py
317
386
  |---|---|---|---|
318
387
  | `common_name` | `str` | — | Certificate CN |
319
388
  | `serial_type` | `CertType` | `SERVICE` | Certificate category |
320
- | `key_size` | `int` | `2048` | RSA key length |
389
+ | `key_size` | `int` | `2048` | RSA key length (≥ 2048; ignored for EC / Ed25519) |
390
+ | `key_algorithm` | `KeyAlgorithm` | `RSA` | `RSA`, `EC_P256`, `EC_P384` or `ED25519` |
321
391
  | `days_valid` | `int` | `3650` | Validity period |
322
392
  | `email` | `EmailStr \| None` | `None` | `emailAddress` Subject attribute |
323
393
  | `is_server_cert` | `bool` | `True` | Adds ServerAuth EKU + CN as DNS SAN |
324
394
  | `is_client_cert` | `bool` | `False` | Adds ClientAuth EKU |
325
395
  | `san_dns` | `list[str] \| None` | `None` | Extra DNS Subject Alternative Names |
326
396
  | `san_ip` | `list[str] \| None` | `None` | IP address SANs |
327
- | `name` | `str \| None` | `None` | Override output file base name |
397
+ | `name` | `str \| None` | `None` | Override output file base name (letters, digits, `.`, `-`, `_`; no leading dot) |
328
398
 
329
399
  ### `CertType`
330
400
 
@@ -351,14 +421,23 @@ storage = LocalStorage(base_folder="./pki")
351
421
  ./pki/
352
422
  └── [cert_path/]
353
423
  └── <uuid>/
354
- ├── nginx.pem ← x509.Certificate
355
- ├── nginx.key ← RSA private key
356
- └── nginx.csr ← CSR
424
+ ├── nginx.pem ← x509.Certificate (0644)
425
+ ├── nginx.key ← private key (0600)
426
+ └── nginx.csr ← CSR (0644)
357
427
  ```
358
428
 
429
+ - Private keys are written with mode `0600`. They are **unencrypted unless**
430
+ `base_encryption_algorithm=BestAvailableEncryption(password)` is passed — a
431
+ warning is logged once per storage instance otherwise.
432
+ - `file_name`, `cert_path` and `uuid_str` are validated: absolute paths, `..`
433
+ segments and backslashes raise `InvalidPathError`.
434
+ - Without `is_overwrite=True` files are created with `O_EXCL`, so an existing
435
+ file is never replaced.
436
+
359
437
  ### `AsyncLocalStorage` (async)
360
438
 
361
- Drop-in async replacement — same constructor, same layout, all I/O via `aiofiles`.
439
+ Drop-in async replacement — same constructor, same layout and the same
440
+ security properties; blocking I/O runs in worker threads.
362
441
 
363
442
  ---
364
443
 
@@ -389,6 +468,13 @@ await db._db.init_db()
389
468
  | `delete_by_uuid(uuid)` | Hard-delete a record |
390
469
  | `update_status_expired()` | Bulk-mark stale VALID records as EXPIRED |
391
470
 
471
+ Read methods (and `update_status_expired`) raise `DBOperationError` on any
472
+ database failure instead of returning `None` / an empty list — a CA must fail
473
+ closed. `register_cert_in_db` returns `False` on failure; the managers turn
474
+ that into `DBOperationError` and remove the artefacts they wrote. Datetimes
475
+ are stored as naive UTC (`DateTime` columns without time zone), which keeps
476
+ PostgreSQL / asyncpg happy.
477
+
392
478
  ---
393
479
 
394
480
  ## Serial Number Encoding
@@ -398,7 +484,7 @@ await db._db.init_db()
398
484
  ```
399
485
  ┌──────────────┬──────────────────────┬────────────────────┐
400
486
  │ 16-bit type │ 80-bit name │ 64-bit random │
401
- │ prefix │ (up to 10 chars) │ (UUID fragment) │
487
+ │ prefix │ (up to 10 chars) │ (secrets.randbits)│
402
488
  └──────────────┴──────────────────────┴────────────────────┘
403
489
  ```
404
490
 
@@ -411,6 +497,10 @@ cert_type, name = SerialWithEncoding.parse(serial)
411
497
  # cert_type == CertType.SERVICE, name == "nginx"
412
498
  ```
413
499
 
500
+ Non-ASCII characters are stored as `?`, so a Cyrillic CN can never overflow
501
+ into the type prefix. The top bit is always zero, so the DER encoding fits the
502
+ 20-octet limit of RFC 5280.
503
+
414
504
  ---
415
505
 
416
506
  ## Error Reference
@@ -427,6 +517,9 @@ cert_type, name = SerialWithEncoding.parse(serial)
427
517
  | `IsNotFile` | CA PEM path is a directory | Provide a file, not a directory |
428
518
  | `WrongType` | Unsupported file extension | Use `.pem`, `.key`, or `.csr` |
429
519
  | `ErrorLoadCert` | PEM deserialisation failed | Check file format and integrity |
520
+ | `DBOperationError` | Registry could not be read / written | Check the database; nothing was trusted or published |
521
+ | `CertRevocationError` | Rotation/renewal could not revoke the old cert | Retry; the replacement was rolled back |
522
+ | `InvalidPathError` | Storage path would escape `base_folder` | Use plain names / relative sub-paths |
430
523
 
431
524
  ---
432
525
 
@@ -436,7 +529,16 @@ cert_type, name = SerialWithEncoding.parse(serial)
436
529
  Yes — load it with `CAFileLoader` or `AsyncCAFileLoader`.
437
530
 
438
531
  **What's the difference between `renew` and `rotate`?**
439
- `renew` keeps the key pair and extends validity. `rotate` generates a new key and revokes the old cert.
532
+ `renew` keeps the key pair and extends validity. `rotate` generates a new key. Both revoke the old cert with reason `superseded`.
533
+
534
+ **Why does `verify_certificate` reject a certificate signed by my CA?**
535
+ Since 0.3.0 a certificate issued directly by the CA must also be present in the
536
+ registry — otherwise its revocation status is unknown. Certificates created
537
+ with older versions via `renew` / `cosign` were not registered; re-issue them.
538
+
539
+ **How do I see the library's logs?**
540
+ `tiny_ca` only attaches a `NullHandler`; configure the `"TinyCA"` logger in your
541
+ application, e.g. `logging.getLogger("TinyCA").setLevel(logging.INFO)`.
440
542
 
441
543
  **How do I schedule CRL regeneration?**
442
544
  Use APScheduler or any cron solution:
@@ -544,4 +646,3 @@ Do **not** open public issues for security vulnerabilities. Email the maintainer
544
646
  | SQLAlchemy | MIT |
545
647
  | Pydantic | MIT |
546
648
  | aiosqlite | MIT |
547
- | aiofiles | Apache 2.0 |