devnomads 0.4.0__tar.gz → 0.5.0__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.
Files changed (39) hide show
  1. {devnomads-0.4.0 → devnomads-0.5.0}/PKG-INFO +1 -1
  2. {devnomads-0.4.0 → devnomads-0.5.0}/pyproject.toml +1 -1
  3. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/client.py +30 -1
  4. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/api/client.py +15 -1
  5. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_acme_client.py +49 -0
  6. {devnomads-0.4.0 → devnomads-0.5.0}/uv.lock +1 -1
  7. {devnomads-0.4.0 → devnomads-0.5.0}/.flake8 +0 -0
  8. {devnomads-0.4.0 → devnomads-0.5.0}/.gitignore +0 -0
  9. {devnomads-0.4.0 → devnomads-0.5.0}/.gitlab-ci.yml +0 -0
  10. {devnomads-0.4.0 → devnomads-0.5.0}/Makefile +0 -0
  11. {devnomads-0.4.0 → devnomads-0.5.0}/README.md +0 -0
  12. {devnomads-0.4.0 → devnomads-0.5.0}/openapi.json +0 -0
  13. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/__init__.py +0 -0
  14. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/__init__.py +0 -0
  15. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/challenge_server.py +0 -0
  16. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/dns01.py +0 -0
  17. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/errors.py +0 -0
  18. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/acme/verify.py +0 -0
  21. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/api/__init__.py +0 -0
  22. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/api/_services.py +0 -0
  23. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/api/credentials.py +0 -0
  24. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/api/errors.py +0 -0
  25. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/dns/__init__.py +0 -0
  26. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/dns/errors.py +0 -0
  27. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/dns/names.py +0 -0
  28. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/dns/zones.py +0 -0
  29. {devnomads-0.4.0 → devnomads-0.5.0}/src/devnomads/py.typed +0 -0
  30. {devnomads-0.4.0 → devnomads-0.5.0}/tests/conftest.py +0 -0
  31. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_acme_challenges.py +0 -0
  32. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_acme_keys.py +0 -0
  33. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_client.py +0 -0
  34. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_credentials.py +0 -0
  35. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_dns.py +0 -0
  36. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_names.py +0 -0
  37. {devnomads-0.4.0 → devnomads-0.5.0}/tests/test_services.py +0 -0
  38. {devnomads-0.4.0 → devnomads-0.5.0}/tools/bump_version.py +0 -0
  39. {devnomads-0.4.0 → devnomads-0.5.0}/tools/generate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
5
  Project-URL: Homepage, https://devnomads.nl
6
6
  Author-email: DevNomads <support@devnomads.nl>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Python client library for the DevNomads API (transport, DNS, ACME)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,6 +12,8 @@ from acme import challenges
12
12
  from acme import client as acme_client
13
13
  from acme import errors as acme_errors
14
14
  from acme import messages
15
+ from cryptography import x509
16
+ from cryptography.x509.oid import NameOID
15
17
 
16
18
  from .dns01 import DnsProvider
17
19
  from .errors import AcmeError, TransientAcmeError
@@ -410,8 +412,12 @@ class AcmeClient:
410
412
  if hasattr(alt, "text"):
411
413
  candidates.append(alt.text)
412
414
  for candidate in candidates:
413
- if self.preferred_chain in candidate:
415
+ if _chain_terminates_at(candidate, self.preferred_chain):
414
416
  return candidate
417
+ log.warning(
418
+ "no offered chain terminates at %r; using the default chain",
419
+ self.preferred_chain,
420
+ )
415
421
  except Exception as exc: # noqa: BLE001
416
422
  log.warning("preferred chain selection failed (%s), using default", exc)
417
423
  return fullchain_pem
@@ -492,6 +498,29 @@ def _authz_failure(authz_body) -> str:
492
498
  return "authorization failed: " + ("; ".join(parts) if parts else "unknown")
493
499
 
494
500
 
501
+ def _chain_terminates_at(chain_pem: str, root_name: str) -> bool:
502
+ """Return whether ``chain_pem`` chains up to a root named ``root_name``.
503
+
504
+ An ACME server never includes the trust anchor itself in the chain it
505
+ serves, so the terminating root is identified by the *issuer* of the
506
+ topmost (last) certificate. ``root_name`` is matched as a substring of that
507
+ issuer's common name, mirroring the ``--preferred-chain`` semantics of
508
+ certbot and dehydrated (e.g. ``"ISRG Root X1"``).
509
+ """
510
+
511
+ try:
512
+ certs = x509.load_pem_x509_certificates(chain_pem.encode())
513
+ except Exception: # noqa: BLE001
514
+ return False
515
+ if not certs:
516
+ return False
517
+ attrs = certs[-1].issuer.get_attributes_for_oid(NameOID.COMMON_NAME)
518
+ issuer_cn: str | bytes = attrs[0].value if attrs else ""
519
+ if isinstance(issuer_cn, bytes):
520
+ issuer_cn = issuer_cn.decode("utf-8", "replace")
521
+ return root_name in issuer_cn
522
+
523
+
495
524
  def _split_fullchain(fullchain_pem: str) -> tuple[str, str]:
496
525
  marker = "-----END CERTIFICATE-----"
497
526
  end = fullchain_pem.find(marker)
@@ -124,12 +124,26 @@ class Client(ResourcesMixin):
124
124
  status=status,
125
125
  detail=_error_detail(response),
126
126
  )
127
+ if 300 <= status < 400:
128
+ location = response.headers.get("location", "")
129
+ raise ApiError(
130
+ f"API returned an unexpected HTTP {status} redirect"
131
+ + (f" to {location}" if location else "")
132
+ + " - the endpoint or credentials may be misrouted",
133
+ status=status,
134
+ detail=location,
135
+ )
127
136
  if status == 204 or not response.content:
128
137
  return None
129
138
  try:
130
139
  return _unwrap(response.json())
131
140
  except ValueError as exc:
132
- raise ApiError(f"API returned invalid JSON: {exc}", status=status) from exc
141
+ snippet = response.text[:200].strip()
142
+ raise ApiError(
143
+ f"API returned invalid JSON (HTTP {status}): {exc}; "
144
+ f"body starts with: {snippet!r}",
145
+ status=status,
146
+ ) from exc
133
147
 
134
148
  def _send(
135
149
  self,
@@ -2,12 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from datetime import datetime, timezone
5
6
  from types import SimpleNamespace
6
7
 
7
8
  import pytest
9
+ from cryptography import x509
10
+ from cryptography.hazmat.primitives import hashes, serialization
11
+ from cryptography.hazmat.primitives.asymmetric import ec
12
+ from cryptography.x509.oid import NameOID
8
13
 
9
14
  from devnomads.acme import AcmeError, TransientAcmeError
10
15
  from devnomads.acme.client import (
16
+ _chain_terminates_at,
11
17
  _challenge_for_authz,
12
18
  _normalize_challenge_types,
13
19
  _normalize_identifiers,
@@ -16,6 +22,23 @@ from devnomads.acme.client import (
16
22
  )
17
23
 
18
24
 
25
+ def _cert_pem(subject_cn: str, issuer_cn: str) -> str:
26
+ """Build a throwaway certificate PEM with the given subject and issuer CN."""
27
+
28
+ key = ec.generate_private_key(ec.SECP256R1())
29
+ cert = (
30
+ x509.CertificateBuilder()
31
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject_cn)]))
32
+ .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer_cn)]))
33
+ .public_key(key.public_key())
34
+ .serial_number(x509.random_serial_number())
35
+ .not_valid_before(datetime(2020, 1, 1, tzinfo=timezone.utc))
36
+ .not_valid_after(datetime(2030, 1, 1, tzinfo=timezone.utc))
37
+ .sign(key, hashes.SHA256())
38
+ )
39
+ return cert.public_bytes(serialization.Encoding.PEM).decode()
40
+
41
+
19
42
  def test_normalize_identifiers_dedup_preserves_order():
20
43
  assert _normalize_identifiers(["a.com", "b.com", "a.com", ""]) == ["a.com", "b.com"]
21
44
 
@@ -380,3 +403,29 @@ def test_answer_dns_challenges_txt_timeout_is_transient(monkeypatch, tmp_path):
380
403
  with pytest.raises(TransientAcmeError) as ei:
381
404
  ac._answer_dns_challenges(client=None, dns_challenges=dns_challenges, delay=0)
382
405
  assert "TXT record not found" in str(ei.value)
406
+
407
+
408
+ def test_chain_terminates_at_matches_topmost_issuer():
409
+ """The terminating root is the issuer of the last cert in the chain."""
410
+
411
+ leaf = _cert_pem("example.com", "Example E5")
412
+ intermediate = _cert_pem("Example E5", "ISRG Root X1")
413
+ chain = leaf + intermediate
414
+
415
+ assert _chain_terminates_at(chain, "ISRG Root X1")
416
+ assert not _chain_terminates_at(chain, "ISRG Root X2")
417
+
418
+
419
+ def test_chain_terminates_at_ignores_lower_certs():
420
+ """Only the topmost cert's issuer counts, not the leaf's."""
421
+
422
+ leaf = _cert_pem("example.com", "ISRG Root X2")
423
+ intermediate = _cert_pem("Example E5", "ISRG Root X1")
424
+ chain = leaf + intermediate
425
+
426
+ assert _chain_terminates_at(chain, "ISRG Root X1")
427
+ assert not _chain_terminates_at(chain, "ISRG Root X2")
428
+
429
+
430
+ def test_chain_terminates_at_bad_pem_is_false():
431
+ assert not _chain_terminates_at("not a certificate", "ISRG Root X1")
@@ -469,7 +469,7 @@ wheels = [
469
469
 
470
470
  [[package]]
471
471
  name = "devnomads"
472
- version = "0.4.0"
472
+ version = "0.5.0"
473
473
  source = { editable = "." }
474
474
  dependencies = [
475
475
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes