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.
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/PKG-INFO +2 -1
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/pyproject.toml +3 -1
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_lifecycle_manager.py +52 -0
- tiny_ca-0.1.2/tests/test_factory_missing_coverage.py +548 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_sync_lifecycle_manager.py +54 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/factory.py +289 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/exc.py +1 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/async_lifecycle_manager.py +105 -19
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/sync_lifecycle_manager.py +85 -2
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/models/certtificate.py +74 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/PKG-INFO +2 -1
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/SOURCES.txt +1 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/requires.txt +1 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/README.md +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/setup.cfg +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_afile_loader.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_db_handler.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_async_local_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_base_db.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_base_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_database_manager.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_db_const.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_factory.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_file_loader.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_life_time.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_local_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_models.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_serial.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_serial_generator.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_storage_const.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tests/test_sync_db_manager.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/afile_loader.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/file_loader.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/life_time.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/ca_factory/utils/serial.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/const.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/async_db_manager.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/base_db.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/const.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/models.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/db/sync_db_manager.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/managers/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/models/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/settings.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/async_local_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/base_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/const.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/storage/local_storage.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/utils/__init__.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca/utils/serial_generator.py +0 -0
- {tiny_ca-0.1.0 → tiny_ca-0.1.2}/tiny_ca.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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.
|
|
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
|