tiny-ca 0.2.1__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.1/tiny_ca.egg-info → tiny_ca-0.3.0}/PKG-INFO +85 -24
  2. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/README.md +83 -20
  3. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/pyproject.toml +4 -5
  4. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_afile_loader.py +23 -4
  5. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_async_db_handler.py +16 -14
  6. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_async_lifecycle_manager.py +1 -1
  7. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_async_local_storage.py +3 -3
  8. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_database_manager.py +6 -2
  9. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_factory.py +6 -4
  10. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_file_loader.py +22 -6
  11. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_life_time.py +17 -29
  12. tiny_ca-0.3.0/tests/test_review_regressions.py +851 -0
  13. tiny_ca-0.3.0/tests/test_review_regressions_async.py +269 -0
  14. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_serial.py +7 -19
  15. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_sync_db_manager.py +20 -18
  16. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/__init__.py +3 -3
  17. tiny_ca-0.3.0/tiny_ca/ca_factory/factory.py +1223 -0
  18. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/afile_loader.py +10 -7
  19. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/file_loader.py +13 -10
  20. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/life_time.py +4 -2
  21. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/const.py +28 -1
  22. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/async_db_manager.py +93 -212
  23. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/sync_db_manager.py +87 -171
  24. tiny_ca-0.3.0/tiny_ca/db/utils.py +67 -0
  25. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/exc.py +33 -0
  26. tiny_ca-0.3.0/tiny_ca/managers/async_lifecycle_manager.py +652 -0
  27. tiny_ca-0.3.0/tiny_ca/managers/sync_lifecycle_manager.py +945 -0
  28. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/models/certificate.py +42 -5
  29. tiny_ca-0.3.0/tiny_ca/settings.py +15 -0
  30. tiny_ca-0.3.0/tiny_ca/storage/async_local_storage.py +118 -0
  31. tiny_ca-0.3.0/tiny_ca/storage/const.py +40 -0
  32. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/storage/local_storage.py +227 -201
  33. tiny_ca-0.3.0/tiny_ca/utils/keys.py +101 -0
  34. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/utils/serial_generator.py +40 -21
  35. {tiny_ca-0.2.1 → tiny_ca-0.3.0/tiny_ca.egg-info}/PKG-INFO +85 -24
  36. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca.egg-info/SOURCES.txt +4 -0
  37. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca.egg-info/requires.txt +1 -3
  38. tiny_ca-0.2.1/tiny_ca/ca_factory/factory.py +0 -1307
  39. tiny_ca-0.2.1/tiny_ca/managers/async_lifecycle_manager.py +0 -871
  40. tiny_ca-0.2.1/tiny_ca/managers/sync_lifecycle_manager.py +0 -885
  41. tiny_ca-0.2.1/tiny_ca/settings.py +0 -9
  42. tiny_ca-0.2.1/tiny_ca/storage/async_local_storage.py +0 -218
  43. tiny_ca-0.2.1/tiny_ca/storage/const.py +0 -39
  44. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/LICENSE +0 -0
  45. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/docs/source/conf.py +0 -0
  46. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/setup.cfg +0 -0
  47. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_base_db.py +0 -0
  48. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_base_storage.py +0 -0
  49. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_db_const.py +0 -0
  50. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_local_storage.py +0 -0
  51. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_models.py +0 -0
  52. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_serial_generator.py +0 -0
  53. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_storage_const.py +0 -0
  54. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tests/test_sync_lifecycle_manager.py +0 -0
  55. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/__init__.py +0 -0
  56. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/__init__.py +0 -0
  57. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/ca_factory/utils/serial.py +0 -0
  58. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/__init__.py +0 -0
  59. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/base_db.py +0 -0
  60. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/const.py +0 -0
  61. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/db/models.py +0 -0
  62. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/managers/__init__.py +0 -0
  63. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/models/__init__.py +0 -0
  64. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/storage/__init__.py +0 -0
  65. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/storage/base_storage.py +0 -0
  66. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca/utils/__init__.py +0 -0
  67. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca.egg-info/dependency_links.txt +0 -0
  68. {tiny_ca-0.2.1 → tiny_ca-0.3.0}/tiny_ca.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiny-ca
3
- Version: 0.2.1
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
@@ -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
@@ -118,14 +116,16 @@ app.include_router(router, prefix="/api/v1")
118
116
  |---|---|
119
117
  | **CA bootstrap** | Self-signed root CA and intermediate (sub) CA with configurable `path_length` |
120
118
  | **Issuance** | Leaf certificates with SANs (DNS + IP), EKU (Server/Client Auth), email attribute |
121
- | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete |
122
- | **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 |
123
123
  | **Inspection** | Structured `CertificateDetails` snapshot from any `x509.Certificate` — fully serialisable |
124
124
  | **Export** | PKCS#12 (`.p12`/`.pfx`) bundles with full CA chain |
125
125
  | **Co-signing** | Re-sign third-party certificates under your CA, preserving Subject and extensions |
126
126
  | **Chain** | Build `[leaf, ca]` fullchain ready for nginx, Apache, or Envoy |
127
127
  | **Monitoring** | List certs with filters, find expiring-soon, bulk-expire stale records |
128
- | **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 |
129
129
  | **Database** | `SyncDBHandler` (SQLAlchemy) and `AsyncDBHandler` (aiosqlite) — SQLite, PostgreSQL, MySQL |
130
130
  | **API parity** | `CertLifecycleManager` and `AsyncCertLifecycleManager` are feature-identical |
131
131
  | **Serial numbers** | `SerialWithEncoding` — 160-bit RFC 5280-compliant, type-prefixed, name-encoded |
@@ -137,7 +137,7 @@ app.include_router(router, prefix="/api/v1")
137
137
 
138
138
  - **Python** 3.11 or higher
139
139
  - **Core:** `cryptography >= 46`, `sqlalchemy >= 2`, `pydantic >= 2`
140
- - **Async extras:** `aiofiles`, `aiosqlite`
140
+ - **Async extras:** `aiosqlite`
141
141
  - **PostgreSQL:** `psycopg2-binary` (sync) / `asyncpg` (async)
142
142
  - **MySQL:** `pymysql` (sync) / `aiomysql` (async)
143
143
 
@@ -158,7 +158,7 @@ CertLifecycleManager / AsyncCertLifecycleManager ← application entry-point
158
158
  │
159
159
  ├── BaseStorage (ABC)
160
160
  │ ├── LocalStorage ← sync filesystem (UUID-isolated)
161
- │ └── AsyncLocalStorage ← async filesystem (aiofiles)
161
+ │ └── AsyncLocalStorage ← async filesystem (worker threads)
162
162
  │
163
163
  └── BaseDB (ABC)
164
164
  ├── SyncDBHandler ← SQLAlchemy sync
@@ -202,7 +202,13 @@ from tiny_ca.storage.local_storage import LocalStorage
202
202
  from tiny_ca.db.sync_db_manager import SyncDBHandler
203
203
  from tiny_ca.models.certificate import CAConfig
204
204
 
205
- 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
+ )
206
212
  db = SyncDBHandler(db_url="sqlite:///pki.db")
207
213
  mgr = CertLifecycleManager(storage=storage, db_handler=db)
208
214
 
@@ -218,10 +224,19 @@ Attach the factory so the manager can issue certificates:
218
224
  from tiny_ca.ca_factory.utils.file_loader import CAFileLoader
219
225
  from tiny_ca.ca_factory.factory import CertificateFactory
220
226
 
221
- loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path)
222
- 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
+ )
223
235
  ```
224
236
 
237
+ `CAConfig` / `ClientConfig` accept `key_algorithm=KeyAlgorithm.EC_P256`
238
+ (also `EC_P384`, `ED25519`, default `RSA`) — see [Configuration Models](#configuration-models).
239
+
225
240
  ### 2. Issue a Leaf Certificate
226
241
 
227
242
  ```python
@@ -245,7 +260,9 @@ cert, key, csr = mgr.issue_certificate(
245
260
  ### 3. Renew a Certificate (same key)
246
261
 
247
262
  Keeps the existing public key — only validity window and serial number change.
248
- 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.
249
266
 
250
267
  ```python
251
268
  renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
@@ -253,7 +270,9 @@ renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
253
270
 
254
271
  ### 4. Rotate a Certificate (new key)
255
272
 
256
- 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.
257
276
 
258
277
  ```python
259
278
  new_cert, new_key, new_csr = mgr.rotate_certificate(
@@ -278,6 +297,10 @@ crl = mgr.generate_crl(days_valid=7) # written to <base_folder>/crl.pem
278
297
  mgr.verify_crl(crl) # raises ValidationCertError on failure
279
298
  ```
280
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
+
281
304
  ### 7. Issue an Intermediate CA
282
305
 
283
306
  ```python
@@ -286,6 +309,10 @@ sub_ca_cert, sub_ca_key = mgr.issue_intermediate_ca(
286
309
  path_length=0, organization="ACME Corp", country="UA",
287
310
  cert_path="intermediate",
288
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])
289
316
  ```
290
317
 
291
318
  ### 8. Export PKCS#12
@@ -303,6 +330,7 @@ with open("nginx.p12", "wb") as f:
303
330
  from cryptography import x509
304
331
  third_party = x509.load_pem_x509_certificate(open("partner.pem", "rb").read())
305
332
  cosigned = mgr.cosign_certificate(cert=third_party, days_valid=365)
333
+ # stored + registered like any other certificate issued by this CA
306
334
  ```
307
335
 
308
336
  ### 10. Inspect a Certificate
@@ -387,7 +415,8 @@ python examples/acomplete_example.py
387
415
  | `common_name` | `str` | `"Internal CA"` | CA Common Name (CN) |
388
416
  | `organization` | `str` | `"My Company"` | Organization (O) |
389
417
  | `country` | `str` | `"UA"` | Two-letter ISO 3166-1 alpha-2 code |
390
- | `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` |
391
420
  | `days_valid` | `int` | `3650` | Validity period in days |
392
421
  | `valid_from` | `datetime \| None` | `None` | Explicit UTC start; `None` = now |
393
422
 
@@ -397,14 +426,15 @@ python examples/acomplete_example.py
397
426
  |---|---|---|---|
398
427
  | `common_name` | `str` | — | Certificate CN |
399
428
  | `serial_type` | `CertType` | `SERVICE` | Certificate category |
400
- | `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` |
401
431
  | `days_valid` | `int` | `3650` | Validity period |
402
432
  | `email` | `EmailStr \| None` | `None` | `emailAddress` Subject attribute |
403
433
  | `is_server_cert` | `bool` | `True` | Adds ServerAuth EKU + CN as DNS SAN |
404
434
  | `is_client_cert` | `bool` | `False` | Adds ClientAuth EKU |
405
435
  | `san_dns` | `list[str] \| None` | `None` | Extra DNS Subject Alternative Names |
406
436
  | `san_ip` | `list[str] \| None` | `None` | IP address SANs |
407
- | `name` | `str \| None` | `None` | Override output file base name |
437
+ | `name` | `str \| None` | `None` | Override output file base name (letters, digits, `.`, `-`, `_`; no leading dot) |
408
438
 
409
439
  ### `CertType`
410
440
 
@@ -431,14 +461,23 @@ storage = LocalStorage(base_folder="./pki")
431
461
  ./pki/
432
462
  └── [cert_path/]
433
463
  └── <uuid>/
434
- ├── nginx.pem ← x509.Certificate
435
- ├── nginx.key ← RSA private key
436
- └── nginx.csr ← CSR
464
+ ├── nginx.pem ← x509.Certificate (0644)
465
+ ├── nginx.key ← private key (0600)
466
+ └── nginx.csr ← CSR (0644)
437
467
  ```
438
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
+
439
477
  ### `AsyncLocalStorage` (async)
440
478
 
441
- 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.
442
481
 
443
482
  ---
444
483
 
@@ -469,6 +508,13 @@ await db._db.init_db()
469
508
  | `delete_by_uuid(uuid)` | Hard-delete a record |
470
509
  | `update_status_expired()` | Bulk-mark stale VALID records as EXPIRED |
471
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
+
472
518
  ---
473
519
 
474
520
  ## Serial Number Encoding
@@ -478,7 +524,7 @@ await db._db.init_db()
478
524
  ```
479
525
  ┌──────────────┬──────────────────────┬────────────────────┐
480
526
  │ 16-bit type │ 80-bit name │ 64-bit random │
481
- │ prefix │ (up to 10 chars) │ (UUID fragment) │
527
+ │ prefix │ (up to 10 chars) │ (secrets.randbits)│
482
528
  └──────────────┴──────────────────────┴────────────────────┘
483
529
  ```
484
530
 
@@ -491,6 +537,10 @@ cert_type, name = SerialWithEncoding.parse(serial)
491
537
  # cert_type == CertType.SERVICE, name == "nginx"
492
538
  ```
493
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
+
494
544
  ---
495
545
 
496
546
  ## Error Reference
@@ -507,6 +557,9 @@ cert_type, name = SerialWithEncoding.parse(serial)
507
557
  | `IsNotFile` | CA PEM path is a directory | Provide a file, not a directory |
508
558
  | `WrongType` | Unsupported file extension | Use `.pem`, `.key`, or `.csr` |
509
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 |
510
563
 
511
564
  ---
512
565
 
@@ -516,7 +569,16 @@ cert_type, name = SerialWithEncoding.parse(serial)
516
569
  Yes — load it with `CAFileLoader` or `AsyncCAFileLoader`.
517
570
 
518
571
  **What's the difference between `renew` and `rotate`?**
519
- `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)`.
520
582
 
521
583
  **How do I schedule CRL regeneration?**
522
584
  Use APScheduler or any cron solution:
@@ -624,4 +686,3 @@ Do **not** open public issues for security vulnerabilities. Email the maintainer
624
686
  | SQLAlchemy | MIT |
625
687
  | Pydantic | MIT |
626
688
  | aiosqlite | MIT |
627
- | aiofiles | Apache 2.0 |
@@ -76,14 +76,16 @@ app.include_router(router, prefix="/api/v1")
76
76
  |---|---|
77
77
  | **CA bootstrap** | Self-signed root CA and intermediate (sub) CA with configurable `path_length` |
78
78
  | **Issuance** | Leaf certificates with SANs (DNS + IP), EKU (Server/Client Auth), email attribute |
79
- | **Lifecycle** | Revoke (RFC 5280 reasons), renew (same key), rotate (new key), hard-delete |
80
- | **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 |
81
83
  | **Inspection** | Structured `CertificateDetails` snapshot from any `x509.Certificate` — fully serialisable |
82
84
  | **Export** | PKCS#12 (`.p12`/`.pfx`) bundles with full CA chain |
83
85
  | **Co-signing** | Re-sign third-party certificates under your CA, preserving Subject and extensions |
84
86
  | **Chain** | Build `[leaf, ca]` fullchain ready for nginx, Apache, or Envoy |
85
87
  | **Monitoring** | List certs with filters, find expiring-soon, bulk-expire stale records |
86
- | **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 |
87
89
  | **Database** | `SyncDBHandler` (SQLAlchemy) and `AsyncDBHandler` (aiosqlite) — SQLite, PostgreSQL, MySQL |
88
90
  | **API parity** | `CertLifecycleManager` and `AsyncCertLifecycleManager` are feature-identical |
89
91
  | **Serial numbers** | `SerialWithEncoding` — 160-bit RFC 5280-compliant, type-prefixed, name-encoded |
@@ -95,7 +97,7 @@ app.include_router(router, prefix="/api/v1")
95
97
 
96
98
  - **Python** 3.11 or higher
97
99
  - **Core:** `cryptography >= 46`, `sqlalchemy >= 2`, `pydantic >= 2`
98
- - **Async extras:** `aiofiles`, `aiosqlite`
100
+ - **Async extras:** `aiosqlite`
99
101
  - **PostgreSQL:** `psycopg2-binary` (sync) / `asyncpg` (async)
100
102
  - **MySQL:** `pymysql` (sync) / `aiomysql` (async)
101
103
 
@@ -116,7 +118,7 @@ CertLifecycleManager / AsyncCertLifecycleManager ← application entry-point
116
118
  │
117
119
  ├── BaseStorage (ABC)
118
120
  │ ├── LocalStorage ← sync filesystem (UUID-isolated)
119
- │ └── AsyncLocalStorage ← async filesystem (aiofiles)
121
+ │ └── AsyncLocalStorage ← async filesystem (worker threads)
120
122
  │
121
123
  └── BaseDB (ABC)
122
124
  ├── SyncDBHandler ← SQLAlchemy sync
@@ -160,7 +162,13 @@ from tiny_ca.storage.local_storage import LocalStorage
160
162
  from tiny_ca.db.sync_db_manager import SyncDBHandler
161
163
  from tiny_ca.models.certificate import CAConfig
162
164
 
163
- 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
+ )
164
172
  db = SyncDBHandler(db_url="sqlite:///pki.db")
165
173
  mgr = CertLifecycleManager(storage=storage, db_handler=db)
166
174
 
@@ -176,10 +184,19 @@ Attach the factory so the manager can issue certificates:
176
184
  from tiny_ca.ca_factory.utils.file_loader import CAFileLoader
177
185
  from tiny_ca.ca_factory.factory import CertificateFactory
178
186
 
179
- loader = CAFileLoader(ca_cert_path=cert_path, ca_key_path=key_path)
180
- 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
+ )
181
195
  ```
182
196
 
197
+ `CAConfig` / `ClientConfig` accept `key_algorithm=KeyAlgorithm.EC_P256`
198
+ (also `EC_P384`, `ED25519`, default `RSA`) — see [Configuration Models](#configuration-models).
199
+
183
200
  ### 2. Issue a Leaf Certificate
184
201
 
185
202
  ```python
@@ -203,7 +220,9 @@ cert, key, csr = mgr.issue_certificate(
203
220
  ### 3. Renew a Certificate (same key)
204
221
 
205
222
  Keeps the existing public key — only validity window and serial number change.
206
- 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.
207
226
 
208
227
  ```python
209
228
  renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
@@ -211,7 +230,9 @@ renewed = mgr.renew_certificate(serial=cert.serial_number, days_valid=365)
211
230
 
212
231
  ### 4. Rotate a Certificate (new key)
213
232
 
214
- 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.
215
236
 
216
237
  ```python
217
238
  new_cert, new_key, new_csr = mgr.rotate_certificate(
@@ -236,6 +257,10 @@ crl = mgr.generate_crl(days_valid=7) # written to <base_folder>/crl.pem
236
257
  mgr.verify_crl(crl) # raises ValidationCertError on failure
237
258
  ```
238
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
+
239
264
  ### 7. Issue an Intermediate CA
240
265
 
241
266
  ```python
@@ -244,6 +269,10 @@ sub_ca_cert, sub_ca_key = mgr.issue_intermediate_ca(
244
269
  path_length=0, organization="ACME Corp", country="UA",
245
270
  cert_path="intermediate",
246
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])
247
276
  ```
248
277
 
249
278
  ### 8. Export PKCS#12
@@ -261,6 +290,7 @@ with open("nginx.p12", "wb") as f:
261
290
  from cryptography import x509
262
291
  third_party = x509.load_pem_x509_certificate(open("partner.pem", "rb").read())
263
292
  cosigned = mgr.cosign_certificate(cert=third_party, days_valid=365)
293
+ # stored + registered like any other certificate issued by this CA
264
294
  ```
265
295
 
266
296
  ### 10. Inspect a Certificate
@@ -345,7 +375,8 @@ python examples/acomplete_example.py
345
375
  | `common_name` | `str` | `"Internal CA"` | CA Common Name (CN) |
346
376
  | `organization` | `str` | `"My Company"` | Organization (O) |
347
377
  | `country` | `str` | `"UA"` | Two-letter ISO 3166-1 alpha-2 code |
348
- | `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` |
349
380
  | `days_valid` | `int` | `3650` | Validity period in days |
350
381
  | `valid_from` | `datetime \| None` | `None` | Explicit UTC start; `None` = now |
351
382
 
@@ -355,14 +386,15 @@ python examples/acomplete_example.py
355
386
  |---|---|---|---|
356
387
  | `common_name` | `str` | — | Certificate CN |
357
388
  | `serial_type` | `CertType` | `SERVICE` | Certificate category |
358
- | `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` |
359
391
  | `days_valid` | `int` | `3650` | Validity period |
360
392
  | `email` | `EmailStr \| None` | `None` | `emailAddress` Subject attribute |
361
393
  | `is_server_cert` | `bool` | `True` | Adds ServerAuth EKU + CN as DNS SAN |
362
394
  | `is_client_cert` | `bool` | `False` | Adds ClientAuth EKU |
363
395
  | `san_dns` | `list[str] \| None` | `None` | Extra DNS Subject Alternative Names |
364
396
  | `san_ip` | `list[str] \| None` | `None` | IP address SANs |
365
- | `name` | `str \| None` | `None` | Override output file base name |
397
+ | `name` | `str \| None` | `None` | Override output file base name (letters, digits, `.`, `-`, `_`; no leading dot) |
366
398
 
367
399
  ### `CertType`
368
400
 
@@ -389,14 +421,23 @@ storage = LocalStorage(base_folder="./pki")
389
421
  ./pki/
390
422
  └── [cert_path/]
391
423
  └── <uuid>/
392
- ├── nginx.pem ← x509.Certificate
393
- ├── nginx.key ← RSA private key
394
- └── nginx.csr ← CSR
424
+ ├── nginx.pem ← x509.Certificate (0644)
425
+ ├── nginx.key ← private key (0600)
426
+ └── nginx.csr ← CSR (0644)
395
427
  ```
396
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
+
397
437
  ### `AsyncLocalStorage` (async)
398
438
 
399
- 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.
400
441
 
401
442
  ---
402
443
 
@@ -427,6 +468,13 @@ await db._db.init_db()
427
468
  | `delete_by_uuid(uuid)` | Hard-delete a record |
428
469
  | `update_status_expired()` | Bulk-mark stale VALID records as EXPIRED |
429
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
+
430
478
  ---
431
479
 
432
480
  ## Serial Number Encoding
@@ -436,7 +484,7 @@ await db._db.init_db()
436
484
  ```
437
485
  ┌──────────────┬──────────────────────┬────────────────────┐
438
486
  │ 16-bit type │ 80-bit name │ 64-bit random │
439
- │ prefix │ (up to 10 chars) │ (UUID fragment) │
487
+ │ prefix │ (up to 10 chars) │ (secrets.randbits)│
440
488
  └──────────────┴──────────────────────┴────────────────────┘
441
489
  ```
442
490
 
@@ -449,6 +497,10 @@ cert_type, name = SerialWithEncoding.parse(serial)
449
497
  # cert_type == CertType.SERVICE, name == "nginx"
450
498
  ```
451
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
+
452
504
  ---
453
505
 
454
506
  ## Error Reference
@@ -465,6 +517,9 @@ cert_type, name = SerialWithEncoding.parse(serial)
465
517
  | `IsNotFile` | CA PEM path is a directory | Provide a file, not a directory |
466
518
  | `WrongType` | Unsupported file extension | Use `.pem`, `.key`, or `.csr` |
467
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 |
468
523
 
469
524
  ---
470
525
 
@@ -474,7 +529,16 @@ cert_type, name = SerialWithEncoding.parse(serial)
474
529
  Yes — load it with `CAFileLoader` or `AsyncCAFileLoader`.
475
530
 
476
531
  **What's the difference between `renew` and `rotate`?**
477
- `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)`.
478
542
 
479
543
  **How do I schedule CRL regeneration?**
480
544
  Use APScheduler or any cron solution:
@@ -582,4 +646,3 @@ Do **not** open public issues for security vulnerabilities. Email the maintainer
582
646
  | SQLAlchemy | MIT |
583
647
  | Pydantic | MIT |
584
648
  | aiosqlite | MIT |
585
- | aiofiles | Apache 2.0 |
@@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta"
10
10
  # ──────────────────────────────────────────────
11
11
  [project]
12
12
  name = "tiny-ca"
13
- version = "0.2.1"
13
+ version = "0.3.0"
14
14
  description = "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."
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -34,7 +34,7 @@ classifiers = [
34
34
  dependencies = [
35
35
  "cryptography>=46.0.0",
36
36
  "pydantic[email]>=2.12.0",
37
- "sqlalchemy>=2.0.0",
37
+ "sqlalchemy[asyncio]>=2.0.0",
38
38
  ]
39
39
 
40
40
  [project.urls]
@@ -48,9 +48,7 @@ Changelog = "https://github.com/Shchusia/tiny_ca/blob/master/CHANGELOG.md"
48
48
  # ──────────────────────────────────────────────
49
49
  [project.optional-dependencies]
50
50
  async = [
51
- "aiofiles>=25.1.0",
52
51
  "aiosqlite>=0.22.0",
53
- "types-aiofiles>=25.1.0",
54
52
  ]
55
53
  postgres = [
56
54
  "psycopg2-binary>=2.9.0",
@@ -138,7 +136,7 @@ fixable = ["ALL"]
138
136
  unfixable = []
139
137
 
140
138
  [tool.ruff.lint.isort]
141
- known-first-party = ["your_package"]
139
+ known-first-party = ["tiny_ca"]
142
140
  force-sort-within-sections = true
143
141
 
144
142
  [tool.ruff.lint.per-file-ignores]
@@ -201,3 +199,4 @@ skips = ["*/*_test.py", "*/test_*.py", "*/grafana.py", "B608"]
201
199
  [tool.pytest.ini_options]
202
200
  pythonpath = ["."]
203
201
  testpaths = ["tests"]
202
+ asyncio_mode = "auto"
@@ -10,6 +10,7 @@ Run with:
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
+ import sys
13
14
  import tempfile
14
15
  from pathlib import Path
15
16
 
@@ -102,7 +103,7 @@ def pem_dir_encrypted(ca_cert: x509.Certificate, ca_key: rsa.RSAPrivateKey):
102
103
 
103
104
 
104
105
  def run(coro):
105
- return asyncio.get_event_loop().run_until_complete(coro)
106
+ return asyncio.run(coro)
106
107
 
107
108
 
108
109
  # ===========================================================================
@@ -287,7 +288,25 @@ class TestLoadSyncErrors:
287
288
  with pytest.raises(ErrorLoadCert):
288
289
  run(loader.load())
289
290
 
290
- def test_non_rsa_key_raises_type_error(self, tmp_path, ca_cert):
291
+ def test_unsupported_key_type_raises_type_error(self, tmp_path, ca_cert):
292
+ from cryptography.hazmat.primitives.asymmetric import dsa
293
+
294
+ bad_key = dsa.generate_private_key(key_size=2048)
295
+ cert_file = tmp_path / "ca.pem"
296
+ key_file = tmp_path / "dsa.pem"
297
+ cert_file.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
298
+ key_file.write_bytes(
299
+ bad_key.private_bytes(
300
+ serialization.Encoding.PEM,
301
+ serialization.PrivateFormat.PKCS8,
302
+ serialization.NoEncryption(),
303
+ )
304
+ )
305
+ loader = AsyncCAFileLoader(cert_file, key_file)
306
+ with pytest.raises(TypeError, match="CA key must be"):
307
+ run(loader.load())
308
+
309
+ def test_ec_key_is_accepted(self, tmp_path, ca_cert):
291
310
  from cryptography.hazmat.primitives.asymmetric import ec
292
311
 
293
312
  ec_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
@@ -302,8 +321,8 @@ class TestLoadSyncErrors:
302
321
  )
303
322
  )
304
323
  loader = AsyncCAFileLoader(cert_file, key_file)
305
- with pytest.raises(TypeError, match="RSA"):
306
- run(loader.load())
324
+ run(loader.load())
325
+ assert isinstance(loader.ca_key, ec.EllipticCurvePrivateKey)
307
326
 
308
327
 
309
328
  # ===========================================================================