"""ACME (Let's Encrypt) client: account handling, dns-01 orders, certificate issuance.""" from __future__ import annotations import datetime import json import logging import re from collections import defaultdict from pathlib import Path from typing import Protocol from urllib.parse import urlparse import josepy as jose from acme import challenges, client, errors, messages from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa from cryptography.x509.oid import NameOID from .config import Config log = logging.getLogger(__name__) USER_AGENT = "wildcard-lets-encrypt-cert-plesk-creator/1.0" ACCOUNT_KEY_BITS = 2048 _CURVES = { "secp256r1": ec.SECP256R1, "prime256v1": ec.SECP256R1, "p-256": ec.SECP256R1, "secp384r1": ec.SECP384R1, "p-384": ec.SECP384R1, } class AcmeFailure(Exception): """Raised when the certificate could not be issued.""" class DnsSolver(Protocol): """Everything the ACME flow needs from the DNS side.""" def add_txt(self, name: str, value: str) -> None: ... def wait_for_propagation(self, expected: dict[str, set[str]]) -> None: ... def cleanup(self) -> None: ... # -------------------------------------------------------------------------- # keys & CSR # -------------------------------------------------------------------------- def generate_private_key(cfg: Config): if cfg.key_type == "ec": curve = _CURVES.get(cfg.ec_curve) if curve is None: raise AcmeFailure(f"Unsupported EC_CURVE {cfg.ec_curve!r} (use secp256r1 or secp384r1).") log.info("Generating EC private key (%s)", cfg.ec_curve) return ec.generate_private_key(curve()) log.info("Generating RSA private key (%d bit)", cfg.rsa_key_size) return rsa.generate_private_key(public_exponent=65537, key_size=cfg.rsa_key_size) def build_csr(private_key, domains: list[str]) -> bytes: """PEM-encoded CSR with *domains* as SANs (first entry also becomes the CN).""" common_name = next((d for d in domains if not d.startswith("*.")), domains[0]) builder = ( x509.CertificateSigningRequestBuilder() .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) .add_extension( x509.SubjectAlternativeName([x509.DNSName(d) for d in domains]), critical=False, ) ) csr = builder.sign(private_key, hashes.SHA256()) return csr.public_bytes(serialization.Encoding.PEM) # -------------------------------------------------------------------------- # ACME # -------------------------------------------------------------------------- class AcmeManager: def __init__(self, cfg: Config): self.cfg = cfg host = urlparse(cfg.acme_directory_url).hostname or "acme" self.account_dir = cfg.acme_account_dir / re.sub(r"[^a-z0-9.-]", "_", host.lower()) self.account_dir.mkdir(parents=True, exist_ok=True) self.account_key_file = self.account_dir / "account_key.json" self.account_regr_file = self.account_dir / "account.json" self.account_key = self._load_or_create_account_key() self.client = self._connect() # -- account ----------------------------------------------------------- def _load_or_create_account_key(self) -> jose.JWKRSA: if self.account_key_file.is_file(): log.info("Using existing ACME account key %s", self.account_key_file) return jose.JWKRSA.json_loads(self.account_key_file.read_text()) log.info("Creating new ACME account key in %s", self.account_key_file) key = jose.JWKRSA( key=rsa.generate_private_key(public_exponent=65537, key_size=ACCOUNT_KEY_BITS) ) self.account_key_file.write_text(key.json_dumps_pretty()) self.account_key_file.chmod(0o600) return key def _connect(self) -> client.ClientV2: net = client.ClientNetwork(self.account_key, user_agent=USER_AGENT) url = self.cfg.acme_directory_url log.info("Connecting to ACME directory %s", url) try: if hasattr(client.ClientV2, "get_directory"): directory = client.ClientV2.get_directory(url, net) else: # pragma: no cover - older acme releases directory = messages.Directory.from_json(net.get(url).json()) except Exception as exc: # noqa: BLE001 - network/protocol errors alike raise AcmeFailure(f"Cannot read the ACME directory at {url}: {exc}") from exc acme_client = client.ClientV2(directory, net=net) self._register(acme_client) return acme_client def _register(self, acme_client: client.ClientV2) -> None: if self.account_regr_file.is_file(): try: regr = messages.RegistrationResource.from_json( json.loads(self.account_regr_file.read_text()) ) acme_client.net.account = regr log.info("Reusing ACME account %s", regr.uri) return except Exception as exc: # noqa: BLE001 - fall back to registration log.warning("Stored ACME account is unusable (%s), registering again.", exc) log.info("Registering ACME account for %s", self.cfg.acme_email) try: regr = acme_client.new_account( messages.NewRegistration.from_data( email=self.cfg.acme_email, terms_of_service_agreed=True ) ) except errors.Error as exc: raise AcmeFailure(f"ACME account registration failed: {exc}") from exc self.account_regr_file.write_text(json.dumps(regr.to_json(), indent=2)) self.account_regr_file.chmod(0o600) log.info("ACME account registered: %s", regr.uri) # -- issuance ---------------------------------------------------------- def obtain_certificate(self, domains: list[str], csr_pem: bytes, solver: DnsSolver) -> str: log.info("Requesting certificate for: %s", ", ".join(domains)) try: order = self.client.new_order(csr_pem) except errors.Error as exc: raise AcmeFailure(f"ACME order could not be created: {exc}") from exc pending: list[tuple[messages.ChallengeBody, str, str]] = [] expected: dict[str, set[str]] = defaultdict(set) for authz in order.authorizations: identifier = authz.body.identifier.value if authz.body.status == messages.STATUS_VALID: log.info("Authorization for %s is still valid - no challenge needed.", identifier) continue challb = self._dns_challenge(authz, identifier) validation = challb.chall.validation(self.account_key) record_name = challb.chall.validation_domain_name(identifier) expected[record_name].add(validation) pending.append((challb, record_name, validation)) if not pending: log.info("All authorizations are already valid.") try: for record_name, validation in ((n, v) for n, vs in expected.items() for v in vs): solver.add_txt(record_name, validation) if expected: solver.wait_for_propagation(dict(expected)) for challb, record_name, _validation in pending: log.info("Answering dns-01 challenge for %s", record_name) self.client.answer_challenge(challb, challb.chall.response(self.account_key)) deadline = datetime.datetime.now() + datetime.timedelta(seconds=300) try: order = self.client.poll_and_finalize(order, deadline) except errors.ValidationError as exc: raise AcmeFailure(self._validation_error_text(exc)) from exc except errors.TimeoutError as exc: raise AcmeFailure( "Timed out waiting for Let's Encrypt to validate/issue the certificate." ) from exc except errors.Error as exc: raise AcmeFailure(f"ACME finalization failed: {exc}") from exc finally: solver.cleanup() if not order.fullchain_pem: raise AcmeFailure("Let's Encrypt returned an empty certificate chain.") log.info("Certificate issued.") return order.fullchain_pem @staticmethod def _dns_challenge(authz, identifier: str) -> messages.ChallengeBody: for challb in authz.body.challenges: if isinstance(challb.chall, challenges.DNS01): return challb raise AcmeFailure( f"Let's Encrypt offered no dns-01 challenge for {identifier} - " "a wildcard certificate cannot be issued without it." ) @staticmethod def _validation_error_text(exc: errors.ValidationError) -> str: lines = ["Let's Encrypt could not validate the DNS challenge:"] for authz in exc.failed_authzrs: domain = authz.body.identifier.value for challb in authz.body.challenges: if challb.error is not None: lines.append(f" - {domain}: {challb.error.detail or challb.error}") break else: lines.append(f" - {domain}: status {authz.body.status}") lines.append( "Check that the _acme-challenge TXT records are served by the authoritative " "nameservers of the zone (is Plesk the DNS master for this domain?)." ) return "\n".join(lines) def certificate_domains(fqdn: str, wildcard: bool, extra_sans: list[str]) -> list[str]: """Build the SAN list: base name, *.base name, plus anything extra.""" domains = [fqdn] if wildcard: domains.append(f"*.{fqdn}") for san in extra_sans: san = san.strip().lower() if san and san not in domains: domains.append(san) return domains def csr_for(cfg: Config, domains: list[str]): key = generate_private_key(cfg) csr = build_csr(key, domains) log.debug("CSR built for %s", domains) return key, csr def parse_chain(fullchain_pem: str) -> list[x509.Certificate]: """Split a PEM chain into certificate objects (leaf first).""" pem_blocks = re.findall( r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", fullchain_pem, re.DOTALL, ) if not pem_blocks: raise AcmeFailure("No certificates found in the ACME response.") return [x509.load_pem_x509_certificate(block.encode()) for block in pem_blocks]