tiny-pki 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tiny_pki/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """tiny-pki — private CA crypto primitives and CLI.
2
+
3
+ Core APIs are bytes-in / bytes-out (no filesystem, no Django). Extracted and
4
+ generalized under MIT from ``the-hcma/my-tracks`` ``app/pki.py``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from tiny_pki.bundle import generate_pkcs12
10
+ from tiny_pki.check import (
11
+ CertificateKind,
12
+ CertificateStatus,
13
+ Status,
14
+ check_certificate,
15
+ check_crl,
16
+ default_warning_window,
17
+ worst_status,
18
+ )
19
+ from tiny_pki.constants import (
20
+ ALLOWED_KEY_SIZES,
21
+ APPLE_MAX_SERVER_VALIDITY_DAYS,
22
+ CLOCK_SKEW_BACKDATE,
23
+ DEFAULT_CA_KEY_SIZE,
24
+ DEFAULT_CA_VALIDITY_DAYS,
25
+ DEFAULT_CLIENT_VALIDITY_DAYS,
26
+ DEFAULT_CRL_VALIDITY_DAYS,
27
+ DEFAULT_KEY_TYPE,
28
+ DEFAULT_LEAF_KEY_SIZE,
29
+ DEFAULT_ORGANIZATION_NAME,
30
+ DEFAULT_SERVER_VALIDITY_DAYS,
31
+ KEY_TYPES,
32
+ MAX_CA_WARNING_DAYS,
33
+ MAX_CLIENT_VALIDITY_DAYS,
34
+ MAX_LEAF_WARNING_DAYS,
35
+ MAX_SERVER_VALIDITY_DAYS,
36
+ MAX_STORE_CRL_VALIDITY_DAYS,
37
+ MAX_VALIDITY_DAYS,
38
+ MIN_PKCS12_PASSWORD_LENGTH,
39
+ VALIDITY_PRESETS,
40
+ KeyType,
41
+ )
42
+ from tiny_pki.errors import TinyPkiError, TinyPkiWarning
43
+ from tiny_pki.inspect import (
44
+ get_certificate_expiry,
45
+ get_certificate_fingerprint,
46
+ get_certificate_issuer,
47
+ get_certificate_metadata,
48
+ get_certificate_sans,
49
+ get_certificate_serial_number,
50
+ get_certificate_subject,
51
+ is_certificate_self_signed,
52
+ )
53
+ from tiny_pki.issue import (
54
+ generate_ca_certificate,
55
+ generate_client_certificate,
56
+ generate_server_certificate,
57
+ max_leaf_validity_days,
58
+ )
59
+ from tiny_pki.revoke import generate_crl
60
+ from tiny_pki.version import package_version
61
+
62
+ __version__ = package_version()
63
+
64
+ __all__ = [
65
+ "ALLOWED_KEY_SIZES",
66
+ "APPLE_MAX_SERVER_VALIDITY_DAYS",
67
+ "CLOCK_SKEW_BACKDATE",
68
+ "CertificateKind",
69
+ "CertificateStatus",
70
+ "DEFAULT_CA_KEY_SIZE",
71
+ "DEFAULT_CA_VALIDITY_DAYS",
72
+ "DEFAULT_CLIENT_VALIDITY_DAYS",
73
+ "DEFAULT_CRL_VALIDITY_DAYS",
74
+ "DEFAULT_KEY_TYPE",
75
+ "DEFAULT_LEAF_KEY_SIZE",
76
+ "DEFAULT_ORGANIZATION_NAME",
77
+ "DEFAULT_SERVER_VALIDITY_DAYS",
78
+ "KEY_TYPES",
79
+ "KeyType",
80
+ "MAX_CA_WARNING_DAYS",
81
+ "MAX_CLIENT_VALIDITY_DAYS",
82
+ "MAX_LEAF_WARNING_DAYS",
83
+ "MAX_SERVER_VALIDITY_DAYS",
84
+ "MAX_STORE_CRL_VALIDITY_DAYS",
85
+ "MAX_VALIDITY_DAYS",
86
+ "MIN_PKCS12_PASSWORD_LENGTH",
87
+ "Status",
88
+ "TinyPkiError",
89
+ "TinyPkiWarning",
90
+ "VALIDITY_PRESETS",
91
+ "__version__",
92
+ "check_certificate",
93
+ "check_crl",
94
+ "default_warning_window",
95
+ "generate_ca_certificate",
96
+ "generate_client_certificate",
97
+ "generate_crl",
98
+ "generate_pkcs12",
99
+ "generate_server_certificate",
100
+ "get_certificate_expiry",
101
+ "get_certificate_fingerprint",
102
+ "get_certificate_issuer",
103
+ "get_certificate_metadata",
104
+ "get_certificate_sans",
105
+ "get_certificate_serial_number",
106
+ "get_certificate_subject",
107
+ "is_certificate_self_signed",
108
+ "max_leaf_validity_days",
109
+ "worst_status",
110
+ ]
@@ -0,0 +1,6 @@
1
+ """Autogenerated by scripts/embed_build_metadata — do not hand-edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ EMBEDDED_COMMIT: str = 'c596cc82e3fa'
6
+ EMBEDDED_VERSION: str = '0.1.0'
tiny_pki/_fsutil.py ADDED
@@ -0,0 +1,55 @@
1
+ """Atomic file writes that never follow a symlink at the destination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ def write_file_atomic(path: Path, data: bytes, *, mode: int) -> None:
12
+ """Replace ``path`` with ``data`` (file mode ``mode``) via a temp file and ``rename``.
13
+
14
+ A symlink already at ``path`` is refused. The temp file is created with
15
+ ``O_EXCL`` in the destination directory, and ``os.replace`` swaps the
16
+ directory entry instead of following a link, so a symlink planted between
17
+ the check and the rename is replaced rather than written through. Readers
18
+ see either the old file or the complete new one, never a truncated file.
19
+ """
20
+ if path.is_symlink():
21
+ raise ValueError(f"Expected {path} to not already exist as a symlink")
22
+ directory = path.parent
23
+ fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=f".{path.name}.", suffix=".tmp")
24
+ tmp = Path(tmp_name)
25
+ try:
26
+ try:
27
+ fchmod = getattr(os, "fchmod", None)
28
+ if fchmod is not None:
29
+ fchmod(fd, mode)
30
+ else:
31
+ tmp.chmod(mode)
32
+ view = memoryview(data)
33
+ while view:
34
+ written = os.write(fd, view)
35
+ if written <= 0:
36
+ raise OSError(f"Expected progress writing {path}, got {written} bytes")
37
+ view = view[written:]
38
+ os.fsync(fd)
39
+ finally:
40
+ os.close(fd)
41
+ os.replace(tmp, path)
42
+ except BaseException:
43
+ tmp.unlink(missing_ok=True)
44
+ raise
45
+ _fsync_directory(directory)
46
+
47
+
48
+ def _fsync_directory(directory: Path) -> None:
49
+ """Persist the rename; best effort because some platforms cannot open a directory."""
50
+ with contextlib.suppress(OSError):
51
+ dir_fd = os.open(directory, os.O_RDONLY)
52
+ try:
53
+ os.fsync(dir_fd)
54
+ finally:
55
+ os.close(dir_fd)
tiny_pki/_keys.py ADDED
@@ -0,0 +1,74 @@
1
+ """Internal helpers for generating and loading RSA and ECDSA P-256 private keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cryptography import x509
6
+ from cryptography.hazmat.primitives import serialization
7
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
8
+
9
+ from tiny_pki.constants import ALLOWED_KEY_SIZES, KEY_TYPES
10
+ from tiny_pki.errors import TinyPkiError
11
+
12
+ PrivateKey = rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey
13
+
14
+
15
+ def generate_private_key(rsa_key_size: int | None) -> PrivateKey:
16
+ """Generate the key :func:`require_key_params` resolved: RSA of that size, or P-256 for ``None``."""
17
+ if rsa_key_size is None:
18
+ return ec.generate_private_key(ec.SECP256R1())
19
+ return rsa.generate_private_key(public_exponent=65537, key_size=rsa_key_size)
20
+
21
+
22
+ def load_private_key(key_pem: bytes) -> PrivateKey:
23
+ """Load an unencrypted RSA or ECDSA P-256 private key from PEM bytes.
24
+
25
+ Raises:
26
+ TinyPkiError: If the PEM is not an unencrypted RSA or P-256 private key
27
+ (including when cryptography raises ``TypeError`` for an
28
+ encrypted key with ``password=None``).
29
+ """
30
+ try:
31
+ key = serialization.load_pem_private_key(key_pem, password=None)
32
+ except TypeError as exc:
33
+ raise TinyPkiError(
34
+ "Expected an unencrypted PEM private key; got an encrypted key "
35
+ f"(password was not given but private key is encrypted): {exc}"
36
+ ) from exc
37
+ if isinstance(key, rsa.RSAPrivateKey):
38
+ return key
39
+ if isinstance(key, ec.EllipticCurvePrivateKey) and isinstance(key.curve, ec.SECP256R1):
40
+ return key
41
+ raise TinyPkiError(f"Expected an RSA or ECDSA P-256 private key, got {_describe(key)}")
42
+
43
+
44
+ def load_ca_private_key(ca_cert: x509.Certificate, ca_key_pem: bytes) -> PrivateKey:
45
+ """Load the CA key with :func:`load_private_key` and require it to match ``ca_cert``.
46
+
47
+ Raises:
48
+ TinyPkiError: If the key cannot be loaded or belongs to a different certificate.
49
+ """
50
+ key = load_private_key(ca_key_pem)
51
+ spki = serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
52
+ if key.public_key().public_bytes(*spki) != ca_cert.public_key().public_bytes(*spki):
53
+ raise TinyPkiError("Expected the CA private key to match the CA certificate's public key")
54
+ return key
55
+
56
+
57
+ def require_key_params(key_type: object, key_size: object, *, default_size: int) -> int | None:
58
+ """Validate ``key_type`` / ``key_size`` and return the RSA size to use (``None`` for EC)."""
59
+ if key_type not in KEY_TYPES:
60
+ raise TinyPkiError(f"Expected key_type in {KEY_TYPES}, got {key_type!r}")
61
+ if key_type == "ec-p256":
62
+ if key_size is not None:
63
+ raise TinyPkiError(f"Expected no key_size with key_type 'ec-p256' (P-256 has a fixed size), got {key_size}")
64
+ return None
65
+ size = default_size if key_size is None else key_size
66
+ if isinstance(size, bool) or not isinstance(size, int) or size not in ALLOWED_KEY_SIZES:
67
+ raise TinyPkiError(f"Expected key_size in {ALLOWED_KEY_SIZES}, got {size!r}")
68
+ return size
69
+
70
+
71
+ def _describe(key: object) -> str:
72
+ if isinstance(key, ec.EllipticCurvePrivateKey):
73
+ return f"an EC key on {key.curve.name}"
74
+ return type(key).__name__
tiny_pki/bundle.py ADDED
@@ -0,0 +1,59 @@
1
+ """PKCS#12 (.p12) bundling for client handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cryptography import x509
6
+ from cryptography.hazmat.primitives import hashes, serialization
7
+ from cryptography.hazmat.primitives.serialization import pkcs12
8
+
9
+ from tiny_pki._keys import load_private_key
10
+ from tiny_pki.constants import MIN_PKCS12_PASSWORD_LENGTH
11
+ from tiny_pki.errors import TinyPkiError
12
+
13
+
14
+ def generate_pkcs12(
15
+ cert_pem: bytes,
16
+ key_pem: bytes,
17
+ ca_cert_pem: bytes,
18
+ friendly_name: str,
19
+ password: bytes,
20
+ *,
21
+ legacy: bool = False,
22
+ ) -> bytes:
23
+ """Bundle cert + key + CA chain into a password-protected PKCS#12 archive.
24
+
25
+ By default the bundle uses ``cryptography``'s best available encryption
26
+ (AES-256-CBC with PBKDF2-HMAC-SHA256 and an HMAC-SHA256 MAC). ``legacy=True``
27
+ uses PBES1 3DES with a SHA-1 MAC instead, for older Android / Apple keychains
28
+ that cannot import the modern format; only use it when a device requires it.
29
+
30
+ Raises:
31
+ TinyPkiError: If ``friendly_name`` is empty or ``password`` is shorter than
32
+ ``MIN_PKCS12_PASSWORD_LENGTH`` bytes.
33
+ """
34
+ if not friendly_name or not friendly_name.strip():
35
+ raise TinyPkiError("Expected a non-empty friendly_name")
36
+ if len(password) < MIN_PKCS12_PASSWORD_LENGTH:
37
+ raise TinyPkiError(f"Expected a password of at least {MIN_PKCS12_PASSWORD_LENGTH} bytes, got {len(password)}")
38
+
39
+ cert = x509.load_pem_x509_certificate(cert_pem)
40
+ key = load_private_key(key_pem)
41
+ ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
42
+
43
+ encryption: serialization.KeySerializationEncryption
44
+ if legacy:
45
+ encryption = (
46
+ serialization.PrivateFormat.PKCS12.encryption_builder()
47
+ .key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC)
48
+ .hmac_hash(hashes.SHA1())
49
+ .build(password)
50
+ )
51
+ else:
52
+ encryption = serialization.BestAvailableEncryption(password)
53
+ return pkcs12.serialize_key_and_certificates(
54
+ name=friendly_name.encode(),
55
+ key=key,
56
+ cert=cert,
57
+ cas=[ca_cert],
58
+ encryption_algorithm=encryption,
59
+ )
tiny_pki/check.py ADDED
@@ -0,0 +1,318 @@
1
+ """Expiry and validity checks for certificates and CRLs (PEM in, status out).
2
+
3
+ A check answers "is this artifact usable now, and will it still be usable at the
4
+ cutoff?". The cutoff is ``now + within``, ``by``, or the earlier of the two;
5
+ with neither, it is ``now + default_warning_window(...)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime, timedelta
13
+ from enum import StrEnum
14
+ from typing import Literal
15
+
16
+ from cryptography import x509
17
+ from cryptography.exceptions import InvalidSignature
18
+ from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
19
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
20
+
21
+ from tiny_pki.constants import MAX_CA_WARNING_DAYS, MAX_LEAF_WARNING_DAYS
22
+ from tiny_pki.errors import TinyPkiError
23
+
24
+ type CertificateKind = Literal["ca", "client", "crl", "server", "unknown"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CertificateStatus:
29
+ """Outcome of :func:`check_certificate` or :func:`check_crl`.
30
+
31
+ ``not_after`` is the effective end of validity: for a leaf checked against
32
+ its CA, the earlier of the leaf's and the CA's ``notAfter``. The default
33
+ warning window still follows the leaf's own lifetime (its renewal cadence). For a CRL,
34
+ ``not_before`` / ``not_after`` are ``lastUpdate`` / ``nextUpdate``.
35
+ """
36
+
37
+ kind: CertificateKind
38
+ subject: str
39
+ issuer: str
40
+ serial_number: int | None
41
+ not_before: datetime
42
+ not_after: datetime | None
43
+ cutoff: datetime
44
+ days_remaining: int | None
45
+ status: Status
46
+ reasons: tuple[str, ...]
47
+
48
+
49
+ class Status(StrEnum):
50
+ """Check outcome, declared from least to most severe."""
51
+
52
+ OK = "ok"
53
+ EXPIRING = "expiring"
54
+ NOT_YET_VALID = "not_yet_valid"
55
+ EXPIRED = "expired"
56
+ REVOKED = "revoked"
57
+ UNTRUSTED = "untrusted"
58
+
59
+ @property
60
+ def severity(self) -> int:
61
+ """Position in the declaration order; higher is worse."""
62
+ return list(Status).index(self)
63
+
64
+
65
+ def check_certificate(
66
+ cert_pem: bytes,
67
+ *,
68
+ now: datetime | None = None,
69
+ within: timedelta | None = None,
70
+ by: datetime | None = None,
71
+ ca_cert_pem: bytes | None = None,
72
+ crl_pem: bytes | None = None,
73
+ ) -> CertificateStatus:
74
+ """Check a certificate's validity now and at the cutoff.
75
+
76
+ Args:
77
+ cert_pem: The certificate to check.
78
+ now: Evaluation time (timezone-aware); defaults to the current time.
79
+ within: Flag certificates that expire within this window of ``now``.
80
+ by: Flag certificates that expire on or before this instant.
81
+ ca_cert_pem: The issuing CA. When given, a bad issuer or signature makes
82
+ the result ``UNTRUSTED``, and a CA that expires first shortens the
83
+ effective validity.
84
+ crl_pem: A CRL from the same CA; a listed serial makes the result
85
+ ``REVOKED``. A CRL past its ``nextUpdate`` makes the result
86
+ ``UNTRUSTED`` (revocation status unknown), since an old CRL may
87
+ predate a revocation. Requires ``ca_cert_pem`` so the CRL's
88
+ signature can be verified.
89
+
90
+ Raises:
91
+ TinyPkiError: On naive datetimes, a negative ``within``, a ``ca_cert_pem``
92
+ that is not a CA certificate, a CRL without a CA, or a CRL not signed
93
+ by the given CA.
94
+ """
95
+ now = _require_aware(now or datetime.now(UTC), "now")
96
+ cert = x509.load_pem_x509_certificate(cert_pem)
97
+ kind = _certificate_kind(cert)
98
+ not_before = cert.not_valid_before_utc
99
+ not_after = cert.not_valid_after_utc
100
+ window = default_warning_window(kind, not_after - not_before)
101
+ reasons: list[tuple[Status, str]] = []
102
+
103
+ if crl_pem is not None and ca_cert_pem is None:
104
+ raise TinyPkiError("Expected ca_cert_pem with crl_pem so the CRL signature can be verified")
105
+ if ca_cert_pem is not None:
106
+ ca_cert = _load_ca(ca_cert_pem)
107
+ if not _is_issued_by(cert, ca_cert):
108
+ reasons.append((Status.UNTRUSTED, f"not issued by {_name_label(ca_cert.subject)}"))
109
+ elif ca_cert.not_valid_after_utc < not_after:
110
+ not_after = ca_cert.not_valid_after_utc
111
+ reasons.append((Status.OK, f"CA expires first ({not_after.isoformat()})"))
112
+ if crl_pem is not None:
113
+ crl = x509.load_pem_x509_crl(crl_pem)
114
+ if not _crl_signed_by(crl, ca_cert):
115
+ raise TinyPkiError(f"Expected a CRL signed by {_name_label(ca_cert.subject)}")
116
+ revoked = crl.get_revoked_certificate_by_serial_number(cert.serial_number)
117
+ if revoked is not None:
118
+ reasons.append((Status.REVOKED, f"revoked on {revoked.revocation_date_utc.isoformat()}"))
119
+ next_update = crl.next_update_utc
120
+ if next_update is not None and now >= next_update:
121
+ reasons.append(
122
+ (Status.UNTRUSTED, f"CRL expired on {next_update.isoformat()}; revocation status unknown")
123
+ )
124
+
125
+ cutoff = _cutoff(now, within=within, by=by, default=window)
126
+ reasons.extend(_time_reasons(now, not_before, not_after, cutoff))
127
+ return _result(
128
+ kind=kind,
129
+ subject=_name_label(cert.subject),
130
+ issuer=_name_label(cert.issuer),
131
+ serial_number=cert.serial_number,
132
+ not_before=not_before,
133
+ not_after=not_after,
134
+ now=now,
135
+ cutoff=cutoff,
136
+ reasons=reasons,
137
+ )
138
+
139
+
140
+ def check_crl(
141
+ crl_pem: bytes,
142
+ *,
143
+ now: datetime | None = None,
144
+ within: timedelta | None = None,
145
+ by: datetime | None = None,
146
+ ca_cert_pem: bytes | None = None,
147
+ ) -> CertificateStatus:
148
+ """Check a CRL's freshness: ``EXPIRED`` once ``nextUpdate`` passes.
149
+
150
+ With ``ca_cert_pem``, a CRL not signed by that CA is ``UNTRUSTED``. A CRL
151
+ without ``nextUpdate`` never goes stale and is reported as ``OK``.
152
+
153
+ Raises:
154
+ TinyPkiError: On naive datetimes, a negative ``within``, or a
155
+ ``ca_cert_pem`` that is not a CA certificate.
156
+ """
157
+ now = _require_aware(now or datetime.now(UTC), "now")
158
+ crl = x509.load_pem_x509_crl(crl_pem)
159
+ reasons: list[tuple[Status, str]] = []
160
+ if ca_cert_pem is not None:
161
+ ca_cert = _load_ca(ca_cert_pem)
162
+ if not _crl_signed_by(crl, ca_cert):
163
+ reasons.append((Status.UNTRUSTED, f"not signed by {_name_label(ca_cert.subject)}"))
164
+ not_before = crl.last_update_utc
165
+ not_after = crl.next_update_utc
166
+ if not_after is None:
167
+ cutoff = _cutoff(now, within=within, by=by, default=timedelta(0))
168
+ reasons.append((Status.OK, "no nextUpdate"))
169
+ else:
170
+ window = default_warning_window("crl", not_after - not_before)
171
+ cutoff = _cutoff(now, within=within, by=by, default=window)
172
+ reasons.extend(_time_reasons(now, not_before, not_after, cutoff))
173
+ try:
174
+ crl_number: int | None = crl.extensions.get_extension_for_class(x509.CRLNumber).value.crl_number
175
+ except x509.ExtensionNotFound:
176
+ crl_number = None
177
+ issuer = _name_label(crl.issuer)
178
+ return _result(
179
+ kind="crl",
180
+ subject=issuer,
181
+ issuer=issuer,
182
+ serial_number=crl_number,
183
+ not_before=not_before,
184
+ not_after=not_after,
185
+ now=now,
186
+ cutoff=cutoff,
187
+ reasons=reasons,
188
+ )
189
+
190
+
191
+ def default_warning_window(kind: CertificateKind, lifetime: timedelta) -> timedelta:
192
+ """One third of ``lifetime``, capped at 180 days for a CA and 30 days for leaves.
193
+
194
+ CRLs are not capped: their window is already short.
195
+ """
196
+ window = max(lifetime, timedelta(0)) / 3
197
+ if kind == "crl":
198
+ return window
199
+ cap_days = MAX_CA_WARNING_DAYS if kind == "ca" else MAX_LEAF_WARNING_DAYS
200
+ return min(window, timedelta(days=cap_days))
201
+
202
+
203
+ def worst_status(results: list[CertificateStatus]) -> Status:
204
+ """Return the most severe status in ``results`` (``OK`` when empty)."""
205
+ return max((r.status for r in results), key=lambda s: s.severity, default=Status.OK)
206
+
207
+
208
+ _CRL_ISSUER_KEY_TYPES = (rsa.RSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)
209
+
210
+
211
+ def _certificate_kind(cert: x509.Certificate) -> CertificateKind:
212
+ try:
213
+ if cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca:
214
+ return "ca"
215
+ except x509.ExtensionNotFound:
216
+ pass
217
+ try:
218
+ usages = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
219
+ except x509.ExtensionNotFound:
220
+ return "unknown"
221
+ if ExtendedKeyUsageOID.SERVER_AUTH in usages:
222
+ return "server"
223
+ if ExtendedKeyUsageOID.CLIENT_AUTH in usages:
224
+ return "client"
225
+ return "unknown"
226
+
227
+
228
+ def _crl_signed_by(crl: x509.CertificateRevocationList, ca_cert: x509.Certificate) -> bool:
229
+ key = ca_cert.public_key()
230
+ if not isinstance(
231
+ key, rsa.RSAPublicKey | ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey | ed448.Ed448PublicKey
232
+ ):
233
+ return False
234
+ return crl.is_signature_valid(key)
235
+
236
+
237
+ def _cutoff(now: datetime, *, within: timedelta | None, by: datetime | None, default: timedelta) -> datetime:
238
+ if within is not None and within < timedelta(0):
239
+ raise TinyPkiError(f"Expected a non-negative within, got {within}")
240
+ candidates: list[datetime] = []
241
+ if within is not None:
242
+ candidates.append(now + within)
243
+ if by is not None:
244
+ candidates.append(_require_aware(by, "by"))
245
+ return min(candidates) if candidates else now + default
246
+
247
+
248
+ def _is_issued_by(cert: x509.Certificate, ca_cert: x509.Certificate) -> bool:
249
+ try:
250
+ cert.verify_directly_issued_by(ca_cert)
251
+ except (InvalidSignature, TypeError, ValueError):
252
+ return False
253
+ return True
254
+
255
+
256
+ def _load_ca(ca_cert_pem: bytes) -> x509.Certificate:
257
+ """Load a trust anchor, rejecting certificates without ``BasicConstraints(ca=True)``."""
258
+ ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)
259
+ try:
260
+ is_ca = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
261
+ except x509.ExtensionNotFound:
262
+ is_ca = False
263
+ if not is_ca:
264
+ raise TinyPkiError(
265
+ f"Expected a CA certificate (BasicConstraints ca=True) for ca_cert_pem, got {_name_label(ca_cert.subject)}"
266
+ )
267
+ return ca_cert
268
+
269
+
270
+ def _name_label(name: x509.Name) -> str:
271
+ cn_attrs = name.get_attributes_for_oid(NameOID.COMMON_NAME)
272
+ return str(cn_attrs[0].value) if cn_attrs else name.rfc4514_string()
273
+
274
+
275
+ def _require_aware(value: datetime, field_name: str) -> datetime:
276
+ if value.tzinfo is None:
277
+ raise TinyPkiError(f"Expected a timezone-aware {field_name}, got naive {value.isoformat()}")
278
+ return value.astimezone(UTC)
279
+
280
+
281
+ def _result(
282
+ *,
283
+ kind: CertificateKind,
284
+ subject: str,
285
+ issuer: str,
286
+ serial_number: int | None,
287
+ not_before: datetime,
288
+ not_after: datetime | None,
289
+ now: datetime,
290
+ cutoff: datetime,
291
+ reasons: list[tuple[Status, str]],
292
+ ) -> CertificateStatus:
293
+ status = max((s for s, _ in reasons), key=lambda s: s.severity, default=Status.OK)
294
+ days_remaining = None if not_after is None else math.floor((not_after - now) / timedelta(days=1))
295
+ return CertificateStatus(
296
+ kind=kind,
297
+ subject=subject,
298
+ issuer=issuer,
299
+ serial_number=serial_number,
300
+ not_before=not_before,
301
+ not_after=not_after,
302
+ cutoff=cutoff,
303
+ days_remaining=days_remaining,
304
+ status=status,
305
+ reasons=tuple(message for _, message in reasons),
306
+ )
307
+
308
+
309
+ def _time_reasons(
310
+ now: datetime, not_before: datetime, not_after: datetime, cutoff: datetime
311
+ ) -> list[tuple[Status, str]]:
312
+ if now < not_before:
313
+ return [(Status.NOT_YET_VALID, f"not valid before {not_before.isoformat()}")]
314
+ if now >= not_after:
315
+ return [(Status.EXPIRED, f"expired on {not_after.isoformat()}")]
316
+ if not_after <= cutoff:
317
+ return [(Status.EXPIRING, f"expires on {not_after.isoformat()}, before the cutoff {cutoff.isoformat()}")]
318
+ return []
@@ -0,0 +1 @@
1
+ """CLI package for tiny-pki (REPL + one-shot commands)."""