devnomads 0.4.1__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.
- {devnomads-0.4.1 → devnomads-0.5.0}/PKG-INFO +1 -1
- {devnomads-0.4.1 → devnomads-0.5.0}/pyproject.toml +1 -1
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/client.py +30 -1
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_acme_client.py +49 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/uv.lock +1 -1
- {devnomads-0.4.1 → devnomads-0.5.0}/.flake8 +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/.gitignore +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/.gitlab-ci.yml +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/Makefile +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/README.md +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/openapi.json +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/__init__.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/__init__.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/errors.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/api/client.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/src/devnomads/py.typed +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/conftest.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_acme_keys.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_client.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_credentials.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_dns.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_names.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tests/test_services.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tools/bump_version.py +0 -0
- {devnomads-0.4.1 → devnomads-0.5.0}/tools/generate.py +0 -0
|
@@ -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
|
|
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)
|
|
@@ -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")
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|