feat: add muxplex.tls module with self-signed cert generation and inspection
- Add muxplex/tls.py with generate_self_signed() and get_cert_info() - generate_self_signed() creates RSA 2048-bit key + X.509 cert with SAN entries (DNS names + 127.0.0.1 + ::1), sets key perms to 0o600, creates parent dirs, returns metadata dict - get_cert_info() inspects PEM certs, returns expires/not_before/ hostnames/serial, returns None for missing/unreadable files - Add muxplex/tests/test_tls.py with 9 tests covering all acceptance criteria - Install cryptography library dependency Co-authored-by: Amplifier <amplifier@anthropic.com>
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
muxplex/tls.py — TLS certificate generation and inspection.
|
||||
|
||||
Provides:
|
||||
generate_self_signed(cert_path, key_path, hostnames=None, days_valid=3650)
|
||||
get_cert_info(cert_path)
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _default_hostnames() -> list[str]:
|
||||
hostname = socket.gethostname()
|
||||
return [hostname, f"{hostname}.local", "localhost"]
|
||||
|
||||
|
||||
def generate_self_signed(
|
||||
cert_path,
|
||||
key_path,
|
||||
hostnames: list[str] | None = None,
|
||||
days_valid: int = 3650,
|
||||
) -> dict:
|
||||
"""Generate a self-signed TLS certificate and private key.
|
||||
|
||||
Args:
|
||||
cert_path: Destination path for the certificate PEM file.
|
||||
key_path: Destination path for the private key PEM file.
|
||||
hostnames: DNS names to include. Defaults to [hostname, hostname.local, localhost].
|
||||
days_valid: Certificate validity period in days. Default 3650 (≈10 years).
|
||||
|
||||
Returns:
|
||||
dict with keys: method, cert_path, key_path, hostnames, expires.
|
||||
"""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
if hostnames is None:
|
||||
hostnames = _default_hostnames()
|
||||
|
||||
cert_path = Path(cert_path)
|
||||
key_path = Path(key_path)
|
||||
|
||||
# Create parent directories if needed
|
||||
cert_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate RSA 2048-bit private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Build subject / issuer with CN = first hostname, O = muxplex
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0]),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "muxplex"),
|
||||
]
|
||||
)
|
||||
|
||||
# Build SAN extension: DNS names for all hostnames + loopback IPs
|
||||
san_entries: list[x509.GeneralName] = [x509.DNSName(h) for h in hostnames]
|
||||
san_entries.append(x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")))
|
||||
san_entries.append(x509.IPAddress(ipaddress.IPv6Address("::1")))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Build the certificate
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(
|
||||
datetime.fromtimestamp(
|
||||
now.timestamp() + days_valid * 86400,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
)
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(san_entries),
|
||||
critical=False,
|
||||
)
|
||||
.sign(private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
expires = cert.not_valid_after_utc # type: ignore[attr-defined]
|
||||
|
||||
# Write key PEM — create file with restricted permissions before writing
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
key_path.touch(mode=0o600, exist_ok=True)
|
||||
key_path.write_bytes(key_pem)
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# Write cert PEM
|
||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||
cert_path.write_bytes(cert_pem)
|
||||
|
||||
return {
|
||||
"method": "selfsigned",
|
||||
"cert_path": str(cert_path),
|
||||
"key_path": str(key_path),
|
||||
"hostnames": hostnames,
|
||||
"expires": expires,
|
||||
}
|
||||
|
||||
|
||||
def get_cert_info(cert_path) -> dict | None:
|
||||
"""Inspect a PEM certificate and return metadata.
|
||||
|
||||
Args:
|
||||
cert_path: Path to the PEM certificate file.
|
||||
|
||||
Returns:
|
||||
dict with expires, not_before, hostnames (DNS names + IPs from SANs), serial.
|
||||
Returns None if the file is missing or cannot be parsed.
|
||||
"""
|
||||
from cryptography import x509
|
||||
from cryptography.x509.extensions import ExtensionNotFound
|
||||
|
||||
cert_path = Path(cert_path)
|
||||
|
||||
try:
|
||||
pem_data = cert_path.read_bytes()
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
return None
|
||||
|
||||
try:
|
||||
cert = x509.load_pem_x509_certificate(pem_data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Extract hostnames from SAN extension
|
||||
hostnames: list[str] = []
|
||||
try:
|
||||
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
for entry in san.value:
|
||||
if isinstance(entry, x509.DNSName):
|
||||
hostnames.append(entry.value)
|
||||
elif isinstance(entry, x509.IPAddress):
|
||||
hostnames.append(str(entry.value))
|
||||
except ExtensionNotFound:
|
||||
pass
|
||||
|
||||
return {
|
||||
"expires": cert.not_valid_after_utc, # type: ignore[attr-defined]
|
||||
"not_before": cert.not_valid_before_utc, # type: ignore[attr-defined]
|
||||
"hostnames": hostnames,
|
||||
"serial": cert.serial_number,
|
||||
}
|
||||
Reference in New Issue
Block a user