tiny-ca 0.1.0__tar.gz → 0.1.2__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 (57) hide show
  1. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/PKG-INFO +2 -1
  2. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/pyproject.toml +3 -1
  3. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_lifecycle_manager.py +52 -0
  4. tiny_ca-0.1.2/tests/test_factory_missing_coverage.py +548 -0
  5. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_sync_lifecycle_manager.py +54 -0
  6. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/factory.py +289 -0
  7. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/exc.py +1 -0
  8. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/async_lifecycle_manager.py +105 -19
  9. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/sync_lifecycle_manager.py +85 -2
  10. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/models/certtificate.py +74 -0
  11. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/PKG-INFO +2 -1
  12. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/SOURCES.txt +1 -0
  13. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/requires.txt +1 -0
  14. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/README.md +0 -0
  15. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/setup.cfg +0 -0
  16. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_afile_loader.py +0 -0
  17. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_db_handler.py +0 -0
  18. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_local_storage.py +0 -0
  19. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_base_db.py +0 -0
  20. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_base_storage.py +0 -0
  21. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_database_manager.py +0 -0
  22. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_db_const.py +0 -0
  23. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_factory.py +0 -0
  24. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_file_loader.py +0 -0
  25. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_life_time.py +0 -0
  26. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_local_storage.py +0 -0
  27. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_models.py +0 -0
  28. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_serial.py +0 -0
  29. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_serial_generator.py +0 -0
  30. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_storage_const.py +0 -0
  31. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_sync_db_manager.py +0 -0
  32. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/__init__.py +0 -0
  33. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/__init__.py +0 -0
  34. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/__init__.py +0 -0
  35. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/afile_loader.py +0 -0
  36. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/file_loader.py +0 -0
  37. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/life_time.py +0 -0
  38. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/serial.py +0 -0
  39. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/const.py +0 -0
  40. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/__init__.py +0 -0
  41. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/async_db_manager.py +0 -0
  42. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/base_db.py +0 -0
  43. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/const.py +0 -0
  44. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/models.py +0 -0
  45. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/sync_db_manager.py +0 -0
  46. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/__init__.py +0 -0
  47. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/models/__init__.py +0 -0
  48. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/settings.py +0 -0
  49. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/__init__.py +0 -0
  50. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/async_local_storage.py +0 -0
  51. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/base_storage.py +0 -0
  52. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/const.py +0 -0
  53. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/local_storage.py +0 -0
  54. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/utils/__init__.py +0 -0
  55. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/utils/serial_generator.py +0 -0
  56. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/dependency_links.txt +0 -0
  57. {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/top_level.txt +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tiny-ca
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: aiofiles>=25.1.0
8
8
  Requires-Dist: aiosqlite>=0.22.1
9
9
  Requires-Dist: cryptography>=46.0.5
10
+ Requires-Dist: pre-commit>=4.5.1
10
11
  Requires-Dist: pydantic[email]>=2.12.5
11
12
  Requires-Dist: sqlalchemy[mypy]>=2.0.48
12
13
  Requires-Dist: types-aiofiles>=25.1.0.20251011
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tiny-ca"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,6 +8,7 @@ dependencies = [
8
8
  "aiofiles>=25.1.0",
9
9
  "aiosqlite>=0.22.1",
10
10
  "cryptography>=46.0.5",
11
+ "pre-commit>=4.5.1",
11
12
  "pydantic[email]>=2.12.5",
12
13
  "sqlalchemy[mypy]>=2.0.48",
13
14
  "types-aiofiles>=25.1.0.20251011",
@@ -39,6 +40,7 @@ docs = [
39
40
  "sphinx-rtd-theme>=3.0.2",
40
41
  ]
41
42
 
43
+
42
44
  [tool.black]
43
45
  line-length = 88
44
46
  target-version = ["py311", "py312"]
@@ -559,3 +559,55 @@ class TestAsyncPersistCertToDB:
559
559
  )
560
560
  assert isinstance(cert, x509.Certificate)
561
561
  mock_db.revoke_certificate.assert_called()
562
+
563
+
564
+ # ===========================================================================
565
+ # inspect_certificate
566
+ # ===========================================================================
567
+
568
+
569
+ class TestAsyncInspectCertificate:
570
+ def test_raises_without_factory(self, storage, mock_db):
571
+ mgr = AsyncCertLifecycleManager(storage=storage, db_handler=mock_db)
572
+ with pytest.raises(ValueError, match="factory"):
573
+ run(mgr.inspect_certificate(MagicMock(spec=x509.Certificate)))
574
+
575
+ def test_returns_certificate_details(self, mgr):
576
+ from tiny_ca.models.certtificate import CertificateDetails
577
+
578
+ cert, _, _ = run(mgr.issue_certificate(_client_config()))
579
+ details = run(mgr.inspect_certificate(cert))
580
+ assert isinstance(details, CertificateDetails)
581
+
582
+ def test_common_name_matches(self, mgr):
583
+ cert, _, _ = run(mgr.issue_certificate(_client_config(common_name="inspect.svc")))
584
+ details = run(mgr.inspect_certificate(cert))
585
+ assert details.common_name == "inspect.svc"
586
+
587
+
588
+ # ===========================================================================
589
+ # cosign_certificate
590
+ # ===========================================================================
591
+
592
+
593
+ class TestAsyncCosignCertificate:
594
+ def test_raises_without_factory(self, storage, mock_db):
595
+ mgr = AsyncCertLifecycleManager(storage=storage, db_handler=mock_db)
596
+ with pytest.raises(ValueError, match="factory"):
597
+ run(mgr.cosign_certificate(MagicMock(spec=x509.Certificate)))
598
+
599
+ def test_returns_certificate(self, mgr):
600
+ cert, _, _ = run(mgr.issue_certificate(_client_config()))
601
+ cosigned = run(mgr.cosign_certificate(cert))
602
+ assert isinstance(cosigned, x509.Certificate)
603
+
604
+ def test_issuer_is_ca(self, mgr, factory):
605
+ cert, _, _ = run(mgr.issue_certificate(_client_config()))
606
+ cosigned = run(mgr.cosign_certificate(cert))
607
+ assert cosigned.issuer == factory._ca.ca_cert.subject
608
+
609
+ def test_days_valid_override(self, mgr):
610
+ cert, _, _ = run(mgr.issue_certificate(_client_config()))
611
+ cosigned = run(mgr.cosign_certificate(cert, days_valid=90))
612
+ delta = cosigned.not_valid_after_utc - cosigned.not_valid_before_utc
613
+ assert delta.days == 90
@@ -0,0 +1,548 @@
1
+ """
2
+ Tests for CertificateFactory.inspect_certificate and
3
+ CertificateFactory.cosign_certificate
4
+
5
+ These two methods (factory.py lines 707-805 and 872-947) were previously
6
+ untested. Adding them here raises factory.py coverage back to 100 %.
7
+
8
+ Run with:
9
+ pytest test_factory_missing_coverage.py -v \
10
+ --cov=tiny_ca.ca_factory.factory --cov-report=term-missing
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+
17
+ import pytest
18
+ from cryptography import x509
19
+ from cryptography.hazmat.backends import default_backend
20
+ from cryptography.hazmat.primitives import hashes, serialization
21
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
22
+ from cryptography.x509.oid import NameOID
23
+
24
+ from tiny_ca import CertificateFactory, ICALoader
25
+ from tiny_ca.exc import InvalidRangeTimeCertificate
26
+ from tiny_ca.models.certtificate import CertificateInfo
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Module-scoped CA fixtures (shared across all test classes)
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def ca_key() -> rsa.RSAPrivateKey:
36
+ return rsa.generate_private_key(65537, 2048, default_backend())
37
+
38
+
39
+ @pytest.fixture(scope="module")
40
+ def ca_cert(ca_key: rsa.RSAPrivateKey) -> x509.Certificate:
41
+ now = datetime.datetime.now(datetime.UTC)
42
+ subject = issuer = x509.Name(
43
+ [
44
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "UA"),
45
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Corp"),
46
+ x509.NameAttribute(NameOID.COMMON_NAME, "Test CA"),
47
+ ]
48
+ )
49
+ return (
50
+ x509.CertificateBuilder()
51
+ .subject_name(subject)
52
+ .issuer_name(issuer)
53
+ .public_key(ca_key.public_key())
54
+ .serial_number(x509.random_serial_number())
55
+ .not_valid_before(now)
56
+ .not_valid_after(now + datetime.timedelta(days=3650))
57
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
58
+ .add_extension(
59
+ x509.KeyUsage(
60
+ digital_signature=False,
61
+ key_encipherment=False,
62
+ content_commitment=False,
63
+ data_encipherment=False,
64
+ key_agreement=False,
65
+ key_cert_sign=True,
66
+ crl_sign=True,
67
+ encipher_only=False,
68
+ decipher_only=False,
69
+ ),
70
+ critical=True,
71
+ )
72
+ .add_extension(
73
+ x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()),
74
+ critical=False,
75
+ )
76
+ .sign(ca_key, hashes.SHA256(), default_backend())
77
+ )
78
+
79
+
80
+ @pytest.fixture(scope="module")
81
+ def ca_loader(ca_cert: x509.Certificate, ca_key: rsa.RSAPrivateKey) -> ICALoader:
82
+ class _Loader:
83
+ @property
84
+ def ca_cert(self):
85
+ return ca_cert
86
+
87
+ @property
88
+ def ca_key(self):
89
+ return ca_key
90
+
91
+ @property
92
+ def base_info(self):
93
+ return CertificateInfo(
94
+ organization="Test Corp",
95
+ organizational_unit=None,
96
+ country="UA",
97
+ state=None,
98
+ locality=None,
99
+ )
100
+
101
+ return _Loader()
102
+
103
+
104
+ @pytest.fixture(scope="module")
105
+ def factory(ca_loader: ICALoader) -> CertificateFactory:
106
+ return CertificateFactory(ca_loader)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Helper: build a leaf cert signed by the test CA with full control over
111
+ # which extensions are included.
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _make_leaf(
116
+ ca_cert: x509.Certificate,
117
+ ca_key: rsa.RSAPrivateKey,
118
+ *,
119
+ common_name: str = "leaf.example.com",
120
+ is_server: bool = True,
121
+ is_client: bool = False,
122
+ san_dns: list[str] | None = None,
123
+ san_ip: list[str] | None = None,
124
+ add_bc: bool = True,
125
+ add_ku: bool = True,
126
+ add_ski: bool = True,
127
+ key: rsa.RSAPrivateKey | None = None,
128
+ ) -> x509.Certificate:
129
+ import ipaddress as _ip
130
+
131
+ if key is None:
132
+ key = rsa.generate_private_key(65537, 2048, default_backend())
133
+ now = datetime.datetime.now(datetime.UTC)
134
+
135
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
136
+ builder = (
137
+ x509.CertificateBuilder()
138
+ .subject_name(subject)
139
+ .issuer_name(ca_cert.subject)
140
+ .public_key(key.public_key())
141
+ .serial_number(x509.random_serial_number())
142
+ .not_valid_before(now)
143
+ .not_valid_after(now + datetime.timedelta(days=365))
144
+ )
145
+ if add_bc:
146
+ builder = builder.add_extension(
147
+ x509.BasicConstraints(ca=False, path_length=None), critical=True
148
+ )
149
+ if add_ku:
150
+ builder = builder.add_extension(
151
+ x509.KeyUsage(
152
+ digital_signature=True,
153
+ key_encipherment=True,
154
+ content_commitment=False,
155
+ data_encipherment=False,
156
+ key_agreement=False,
157
+ key_cert_sign=False,
158
+ crl_sign=False,
159
+ encipher_only=False,
160
+ decipher_only=False,
161
+ ),
162
+ critical=False,
163
+ )
164
+
165
+ # EKU
166
+ eku_oids = []
167
+ if is_server:
168
+ from cryptography.x509.oid import ExtendedKeyUsageOID
169
+
170
+ eku_oids.append(ExtendedKeyUsageOID.SERVER_AUTH)
171
+ if is_client:
172
+ from cryptography.x509.oid import ExtendedKeyUsageOID
173
+
174
+ eku_oids.append(ExtendedKeyUsageOID.CLIENT_AUTH)
175
+ if eku_oids:
176
+ builder = builder.add_extension(
177
+ x509.ExtendedKeyUsage(eku_oids), critical=False
178
+ )
179
+
180
+ # SAN
181
+ san_names: list[x509.GeneralName] = []
182
+ if is_server:
183
+ san_names.append(x509.DNSName(common_name))
184
+ for d in san_dns or []:
185
+ san_names.append(x509.DNSName(d))
186
+ for ip in san_ip or []:
187
+ san_names.append(x509.IPAddress(_ip.ip_address(ip)))
188
+ if san_names:
189
+ builder = builder.add_extension(
190
+ x509.SubjectAlternativeName(san_names), critical=False
191
+ )
192
+
193
+ if add_ski:
194
+ builder = builder.add_extension(
195
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False
196
+ )
197
+
198
+ return builder.sign(ca_key, hashes.SHA256(), default_backend())
199
+
200
+
201
+ # ===========================================================================
202
+ # inspect_certificate (lines 707–805)
203
+ # ===========================================================================
204
+
205
+
206
+ class TestInspectCertificate:
207
+ """Cover every branch inside CertificateFactory.inspect_certificate."""
208
+
209
+ # ── basic return type ──────────────────────────────────────────────────
210
+
211
+ def test_returns_certificate_details(self, factory, ca_cert, ca_key):
212
+ from tiny_ca.models.certtificate import CertificateDetails
213
+
214
+ leaf = _make_leaf(ca_cert, ca_key)
215
+ details = factory.inspect_certificate(leaf)
216
+ assert isinstance(details, CertificateDetails)
217
+
218
+ # ── Subject attributes ─────────────────────────────────────────────────
219
+
220
+ def test_common_name_extracted(self, factory, ca_cert, ca_key):
221
+ leaf = _make_leaf(ca_cert, ca_key, common_name="myservice.internal")
222
+ assert factory.inspect_certificate(leaf).common_name == "myservice.internal"
223
+
224
+ def test_missing_subject_attrs_are_none(self, factory, ca_cert, ca_key):
225
+ """Cert with CN-only Subject → organization and country are None."""
226
+ details = factory.inspect_certificate(_make_leaf(ca_cert, ca_key))
227
+ assert details.organization is None
228
+ assert details.country is None
229
+
230
+ def test_issuer_cn_extracted(self, factory, ca_cert, ca_key):
231
+ leaf = _make_leaf(ca_cert, ca_key)
232
+ assert factory.inspect_certificate(leaf).issuer_cn == "Test CA"
233
+
234
+ # ── BasicConstraints ──────────────────────────────────────────────────
235
+
236
+ def test_is_ca_false_for_leaf(self, factory, ca_cert, ca_key):
237
+ leaf = _make_leaf(ca_cert, ca_key)
238
+ assert factory.inspect_certificate(leaf).is_ca is False
239
+
240
+ def test_is_ca_true_for_ca_cert(self, factory, ca_cert):
241
+ """CA cert itself has BasicConstraints(ca=True)."""
242
+ assert factory.inspect_certificate(ca_cert).is_ca is True
243
+
244
+ def test_is_ca_false_when_no_basic_constraints(self, factory, ca_cert, ca_key):
245
+ """Cert without BasicConstraints extension → is_ca defaults to False."""
246
+ leaf = _make_leaf(ca_cert, ca_key, add_bc=False)
247
+ assert factory.inspect_certificate(leaf).is_ca is False
248
+
249
+ # ── SubjectAlternativeName ─────────────────────────────────────────────
250
+
251
+ def test_san_dns_populated(self, factory, ca_cert, ca_key):
252
+ leaf = _make_leaf(
253
+ ca_cert, ca_key, is_server=True, san_dns=["alt1.example.com"]
254
+ )
255
+ details = factory.inspect_certificate(leaf)
256
+ assert "alt1.example.com" in details.san_dns
257
+
258
+ def test_san_ip_populated(self, factory, ca_cert, ca_key):
259
+ leaf = _make_leaf(ca_cert, ca_key, is_server=False, san_ip=["10.0.0.1"])
260
+ details = factory.inspect_certificate(leaf)
261
+ assert "10.0.0.1" in details.san_ip
262
+
263
+ def test_no_san_extension_gives_empty_lists(self, factory, ca_cert, ca_key):
264
+ """Cert without any SAN → both san_dns and san_ip are empty."""
265
+ leaf = _make_leaf(ca_cert, ca_key, is_server=False)
266
+ details = factory.inspect_certificate(leaf)
267
+ assert details.san_dns == []
268
+ assert details.san_ip == []
269
+
270
+ # ── KeyUsage ──────────────────────────────────────────────────────────
271
+
272
+ def test_key_usage_populated(self, factory, ca_cert, ca_key):
273
+ leaf = _make_leaf(ca_cert, ca_key, add_ku=True)
274
+ details = factory.inspect_certificate(leaf)
275
+ assert "digital_signature" in details.key_usage
276
+ assert "key_encipherment" in details.key_usage
277
+
278
+ def test_no_key_usage_extension_gives_empty_list(self, factory, ca_cert, ca_key):
279
+ leaf = _make_leaf(ca_cert, ca_key, add_ku=False)
280
+ details = factory.inspect_certificate(leaf)
281
+ assert details.key_usage == []
282
+
283
+ # ── ExtendedKeyUsage ──────────────────────────────────────────────────
284
+
285
+ def test_extended_key_usage_populated(self, factory, ca_cert, ca_key):
286
+ from cryptography.x509.oid import ExtendedKeyUsageOID
287
+
288
+ leaf = _make_leaf(ca_cert, ca_key, is_server=True)
289
+ details = factory.inspect_certificate(leaf)
290
+ assert ExtendedKeyUsageOID.SERVER_AUTH.dotted_string in details.extended_key_usage
291
+
292
+ def test_no_eku_extension_gives_empty_list(self, factory, ca_cert, ca_key):
293
+ leaf = _make_leaf(ca_cert, ca_key, is_server=False, is_client=False)
294
+ details = factory.inspect_certificate(leaf)
295
+ assert details.extended_key_usage == []
296
+
297
+ # ── Fingerprint ───────────────────────────────────────────────────────
298
+
299
+ def test_fingerprint_is_colon_hex(self, factory, ca_cert, ca_key):
300
+ leaf = _make_leaf(ca_cert, ca_key)
301
+ fp = factory.inspect_certificate(leaf).fingerprint_sha256
302
+ # Format: "AB:CD:EF:..." — 32 bytes × 3 chars per byte - 1 colon
303
+ assert ":" in fp
304
+ assert all(c in "0123456789ABCDEF:" for c in fp)
305
+
306
+ # ── SubjectKeyIdentifier ──────────────────────────────────────────────
307
+
308
+ def test_ski_extracted_when_present(self, factory, ca_cert, ca_key):
309
+ leaf = _make_leaf(ca_cert, ca_key, add_ski=True)
310
+ ski = factory.inspect_certificate(leaf).subject_key_identifier
311
+ assert ski is not None
312
+ assert isinstance(ski, str)
313
+
314
+ def test_ski_is_none_when_absent(self, factory, ca_cert, ca_key):
315
+ leaf = _make_leaf(ca_cert, ca_key, add_ski=False)
316
+ assert factory.inspect_certificate(leaf).subject_key_identifier is None
317
+
318
+ # ── Public key size ───────────────────────────────────────────────────
319
+
320
+ def test_rsa_key_size_extracted(self, factory, ca_cert, ca_key):
321
+ leaf = _make_leaf(ca_cert, ca_key)
322
+ assert factory.inspect_certificate(leaf).public_key_size == 2048
323
+
324
+ def test_non_rsa_key_size_is_none(self, factory, ca_cert, ca_key):
325
+ """EC public key → public_key_size should be None."""
326
+ ec_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
327
+ now = datetime.datetime.now(datetime.UTC)
328
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "ec.svc")])
329
+ ec_cert = (
330
+ x509.CertificateBuilder()
331
+ .subject_name(subject)
332
+ .issuer_name(ca_cert.subject)
333
+ .public_key(ec_key.public_key())
334
+ .serial_number(x509.random_serial_number())
335
+ .not_valid_before(now)
336
+ .not_valid_after(now + datetime.timedelta(days=365))
337
+ .sign(ca_key, hashes.SHA256(), default_backend())
338
+ )
339
+ assert factory.inspect_certificate(ec_cert).public_key_size is None
340
+
341
+ # ── serial_number passthrough ─────────────────────────────────────────
342
+
343
+ def test_serial_number_matches(self, factory, ca_cert, ca_key):
344
+ leaf = _make_leaf(ca_cert, ca_key)
345
+ assert factory.inspect_certificate(leaf).serial_number == leaf.serial_number
346
+
347
+
348
+ # ===========================================================================
349
+ # cosign_certificate (lines 872–947)
350
+ # ===========================================================================
351
+
352
+
353
+ class TestCosignCertificate:
354
+ """Cover every branch inside CertificateFactory.cosign_certificate."""
355
+
356
+ # ── basic smoke test ───────────────────────────────────────────────────
357
+
358
+ def test_returns_certificate(self, factory, ca_cert, ca_key):
359
+ leaf = _make_leaf(ca_cert, ca_key)
360
+ cosigned = factory.cosign_certificate(leaf)
361
+ assert isinstance(cosigned, x509.Certificate)
362
+
363
+ # ── issuer replacement ────────────────────────────────────────────────
364
+
365
+ def test_issuer_is_ca_subject(self, factory, ca_cert, ca_key):
366
+ leaf = _make_leaf(ca_cert, ca_key)
367
+ cosigned = factory.cosign_certificate(leaf)
368
+ assert cosigned.issuer == ca_cert.subject
369
+
370
+ def test_subject_preserved(self, factory, ca_cert, ca_key):
371
+ leaf = _make_leaf(ca_cert, ca_key, common_name="preserved.svc")
372
+ cosigned = factory.cosign_certificate(leaf)
373
+ assert cosigned.subject == leaf.subject
374
+
375
+ # ── serial number ─────────────────────────────────────────────────────
376
+
377
+ def test_serial_number_is_fresh(self, factory, ca_cert, ca_key):
378
+ leaf = _make_leaf(ca_cert, ca_key)
379
+ cosigned = factory.cosign_certificate(leaf)
380
+ assert cosigned.serial_number != leaf.serial_number
381
+
382
+ # ── validity window: preserve original when days_valid is None ─────────
383
+
384
+ def test_preserves_original_validity_when_no_override(self, factory, ca_cert, ca_key):
385
+ leaf = _make_leaf(ca_cert, ca_key)
386
+ cosigned = factory.cosign_certificate(leaf)
387
+ # Timestamps may differ by microseconds due to UTC rounding; compare dates
388
+ assert cosigned.not_valid_before_utc.date() == leaf.not_valid_before_utc.date()
389
+ assert cosigned.not_valid_after_utc.date() == leaf.not_valid_after_utc.date()
390
+
391
+ # ── validity window: override with days_valid ─────────────────────────
392
+
393
+ def test_days_valid_overrides_expiry(self, factory, ca_cert, ca_key):
394
+ leaf = _make_leaf(ca_cert, ca_key)
395
+ cosigned = factory.cosign_certificate(leaf, days_valid=90)
396
+ delta = cosigned.not_valid_after_utc - cosigned.not_valid_before_utc
397
+ assert delta.days == 90
398
+
399
+ def test_valid_from_used_when_days_valid_provided(self, factory, ca_cert, ca_key):
400
+ leaf = _make_leaf(ca_cert, ca_key)
401
+ vf = datetime.datetime.now(datetime.UTC)
402
+ cosigned = factory.cosign_certificate(leaf, days_valid=30, valid_from=vf)
403
+ assert cosigned.not_valid_before_utc.date() == vf.date()
404
+
405
+ def test_past_days_valid_raises(self, factory, ca_cert, ca_key):
406
+ """days_valid that produces an already-expired cert must raise."""
407
+ leaf = _make_leaf(ca_cert, ca_key)
408
+ past_start = datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC)
409
+ with pytest.raises(InvalidRangeTimeCertificate):
410
+ factory.cosign_certificate(leaf, days_valid=1, valid_from=past_start)
411
+
412
+ # ── AKI from SKI (happy path — CA has SKI) ────────────────────────────
413
+
414
+ def test_aki_updated_to_ca_ski(self, factory, ca_cert, ca_key):
415
+ leaf = _make_leaf(ca_cert, ca_key)
416
+ cosigned = factory.cosign_certificate(leaf)
417
+ aki = cosigned.extensions.get_extension_for_class(
418
+ x509.AuthorityKeyIdentifier
419
+ ).value
420
+ # The AKI key_identifier must equal the CA's SKI digest
421
+ ca_ski = ca_cert.extensions.get_extension_for_class(
422
+ x509.SubjectKeyIdentifier
423
+ ).value
424
+ assert aki.key_identifier == ca_ski.digest
425
+
426
+ # ── AKI fallback: CA has no SKI ───────────────────────────────────────
427
+
428
+ def test_aki_fallback_when_ca_has_no_ski(self, ca_cert, ca_key):
429
+ """When the CA cert has no SKI extension, cosign falls back to
430
+ issuer-name + serial form for the AKI."""
431
+ now = datetime.datetime.now(datetime.UTC)
432
+ subject = issuer = x509.Name(
433
+ [x509.NameAttribute(NameOID.COMMON_NAME, "No-SKI CA")]
434
+ )
435
+ # Build a CA cert *without* SubjectKeyIdentifier
436
+ ca_no_ski = (
437
+ x509.CertificateBuilder()
438
+ .subject_name(subject)
439
+ .issuer_name(issuer)
440
+ .public_key(ca_key.public_key())
441
+ .serial_number(x509.random_serial_number())
442
+ .not_valid_before(now)
443
+ .not_valid_after(now + datetime.timedelta(days=3650))
444
+ .add_extension(
445
+ x509.BasicConstraints(ca=True, path_length=None), critical=True
446
+ )
447
+ .sign(ca_key, hashes.SHA256(), default_backend())
448
+ )
449
+
450
+ class _NoSKILoader:
451
+ @property
452
+ def ca_cert(self):
453
+ return ca_no_ski
454
+
455
+ @property
456
+ def ca_key(self):
457
+ return ca_key
458
+
459
+ @property
460
+ def base_info(self):
461
+ return CertificateInfo(
462
+ organization=None,
463
+ organizational_unit=None,
464
+ country=None,
465
+ state=None,
466
+ locality=None,
467
+ )
468
+
469
+ no_ski_factory = CertificateFactory(_NoSKILoader())
470
+ leaf = _make_leaf(ca_cert, ca_key, add_ski=False)
471
+ cosigned = no_ski_factory.cosign_certificate(leaf)
472
+ # Should have an AKI extension (fallback form), not raise
473
+ aki = cosigned.extensions.get_extension_for_class(
474
+ x509.AuthorityKeyIdentifier
475
+ ).value
476
+ assert aki is not None
477
+
478
+ # ── cert with no CN (edge case in logging / serial generation) ─────────
479
+
480
+ def test_cosign_cert_without_cn(self, factory, ca_cert, ca_key):
481
+ """Cert whose Subject has no CN — falls back to 'cosigned' placeholder."""
482
+ now = datetime.datetime.now(datetime.UTC)
483
+ no_cn_subject = x509.Name(
484
+ [x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Org Only")]
485
+ )
486
+ no_cn_cert = (
487
+ x509.CertificateBuilder()
488
+ .subject_name(no_cn_subject)
489
+ .issuer_name(ca_cert.subject)
490
+ .public_key(rsa.generate_private_key(65537, 2048, default_backend()).public_key())
491
+ .serial_number(x509.random_serial_number())
492
+ .not_valid_before(now)
493
+ .not_valid_after(now + datetime.timedelta(days=365))
494
+ .sign(ca_key, hashes.SHA256(), default_backend())
495
+ )
496
+ cosigned = factory.cosign_certificate(no_cn_cert)
497
+ assert isinstance(cosigned, x509.Certificate)
498
+
499
+ # ── extension copying ─────────────────────────────────────────────────
500
+
501
+ def test_extensions_copied_except_aki(self, factory, ca_cert, ca_key):
502
+ """All extensions except AKI should be present in the co-signed cert."""
503
+ leaf = _make_leaf(ca_cert, ca_key, is_server=True, add_ski=True)
504
+ cosigned = factory.cosign_certificate(leaf)
505
+ # BasicConstraints should be copied through
506
+ bc = cosigned.extensions.get_extension_for_class(x509.BasicConstraints)
507
+ assert bc.value.ca is False
508
+ # SAN should be copied through
509
+ san = cosigned.extensions.get_extension_for_class(x509.SubjectAlternativeName)
510
+ assert san is not None
511
+
512
+ def test_aki_skipped_when_source_cert_has_aki(self, factory, ca_cert, ca_key):
513
+ """Source cert that already has an AKI — the continue branch (line 914)
514
+ must fire to skip it and replace it with the CA's own AKI."""
515
+ # Build a leaf that already carries an AKI referencing the CA's SKI.
516
+ ca_ski = ca_cert.extensions.get_extension_for_class(
517
+ x509.SubjectKeyIdentifier
518
+ ).value
519
+ leaf_key = rsa.generate_private_key(65537, 2048, default_backend())
520
+ now = datetime.datetime.now(datetime.UTC)
521
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "aki.svc")])
522
+ leaf_with_aki = (
523
+ x509.CertificateBuilder()
524
+ .subject_name(subject)
525
+ .issuer_name(ca_cert.subject)
526
+ .public_key(leaf_key.public_key())
527
+ .serial_number(x509.random_serial_number())
528
+ .not_valid_before(now)
529
+ .not_valid_after(now + datetime.timedelta(days=365))
530
+ .add_extension(
531
+ x509.BasicConstraints(ca=False, path_length=None), critical=True
532
+ )
533
+ .add_extension(
534
+ x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()),
535
+ critical=False,
536
+ )
537
+ .add_extension(
538
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ca_ski),
539
+ critical=False,
540
+ )
541
+ .sign(ca_key, hashes.SHA256(), default_backend())
542
+ )
543
+ cosigned = factory.cosign_certificate(leaf_with_aki)
544
+ # The AKI in the output must still point at the factory's CA (not be absent)
545
+ aki = cosigned.extensions.get_extension_for_class(
546
+ x509.AuthorityKeyIdentifier
547
+ ).value
548
+ assert aki.key_identifier == ca_ski.digest