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 +110 -0
- tiny_pki/_build_metadata.py +6 -0
- tiny_pki/_fsutil.py +55 -0
- tiny_pki/_keys.py +74 -0
- tiny_pki/bundle.py +59 -0
- tiny_pki/check.py +318 -0
- tiny_pki/cli/__init__.py +1 -0
- tiny_pki/cli/commands.py +176 -0
- tiny_pki/cli/completer.py +91 -0
- tiny_pki/cli/completion.py +368 -0
- tiny_pki/cli/entry.py +27 -0
- tiny_pki/cli/handlers.py +969 -0
- tiny_pki/cli/main.py +281 -0
- tiny_pki/cli/theme.py +51 -0
- tiny_pki/constants.py +48 -0
- tiny_pki/errors.py +16 -0
- tiny_pki/inspect.py +99 -0
- tiny_pki/issue.py +613 -0
- tiny_pki/names.py +158 -0
- tiny_pki/py.typed +0 -0
- tiny_pki/revoke.py +88 -0
- tiny_pki/secrets.py +87 -0
- tiny_pki/store.py +1116 -0
- tiny_pki/version.py +111 -0
- tiny_pki-0.1.0.dist-info/METADATA +154 -0
- tiny_pki-0.1.0.dist-info/RECORD +29 -0
- tiny_pki-0.1.0.dist-info/WHEEL +4 -0
- tiny_pki-0.1.0.dist-info/entry_points.txt +2 -0
- tiny_pki-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
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 []
|
tiny_pki/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for tiny-pki (REPL + one-shot commands)."""
|