devnomads 0.2.4__tar.gz → 0.2.5__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.2.4 → devnomads-0.2.5}/PKG-INFO +36 -1
  2. {devnomads-0.2.4 → devnomads-0.2.5}/README.md +35 -0
  3. {devnomads-0.2.4 → devnomads-0.2.5}/pyproject.toml +1 -1
  4. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/__init__.py +12 -1
  5. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/client.py +72 -2
  6. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_acme_client.py +104 -0
  7. {devnomads-0.2.4 → devnomads-0.2.5}/.flake8 +0 -0
  8. {devnomads-0.2.4 → devnomads-0.2.5}/.gitignore +0 -0
  9. {devnomads-0.2.4 → devnomads-0.2.5}/.gitlab-ci.yml +0 -0
  10. {devnomads-0.2.4 → devnomads-0.2.5}/Makefile +0 -0
  11. {devnomads-0.2.4 → devnomads-0.2.5}/openapi.json +0 -0
  12. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/__init__.py +0 -0
  13. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/challenge_server.py +0 -0
  14. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/dns01.py +0 -0
  15. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/errors.py +0 -0
  16. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/http01.py +0 -0
  17. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/keys.py +0 -0
  18. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/acme/verify.py +0 -0
  19. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/api/__init__.py +0 -0
  20. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/api/_services.py +0 -0
  21. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/api/client.py +0 -0
  22. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/api/credentials.py +0 -0
  23. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/api/errors.py +0 -0
  24. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/dns/__init__.py +0 -0
  25. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/dns/errors.py +0 -0
  26. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/dns/names.py +0 -0
  27. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/dns/zones.py +0 -0
  28. {devnomads-0.2.4 → devnomads-0.2.5}/src/devnomads/py.typed +0 -0
  29. {devnomads-0.2.4 → devnomads-0.2.5}/tests/conftest.py +0 -0
  30. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_acme_challenges.py +0 -0
  31. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_acme_keys.py +0 -0
  32. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_client.py +0 -0
  33. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_credentials.py +0 -0
  34. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_dns.py +0 -0
  35. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_names.py +0 -0
  36. {devnomads-0.2.4 → devnomads-0.2.5}/tests/test_services.py +0 -0
  37. {devnomads-0.2.4 → devnomads-0.2.5}/tools/bump_version.py +0 -0
  38. {devnomads-0.2.4 → devnomads-0.2.5}/tools/generate.py +0 -0
  39. {devnomads-0.2.4 → devnomads-0.2.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads
3
- Version: 0.2.4
3
+ Version: 0.2.5
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>
@@ -320,9 +320,16 @@ AcmeClient(
320
320
  preferred_chain=None, # match alternate chain by CN
321
321
  recursive_nameservers=None, # resolvers for propagation
322
322
  user_agent="devnomads",
323
+ eab_kid=None, # external account binding key id
324
+ eab_hmac_key=None, # external account binding HMAC key
325
+ eab_hmac_alg="HS256", # HS256/HS384/HS512
323
326
  )
324
327
  ```
325
328
 
329
+ Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
330
+ `directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
331
+ also need `eab_kid` and `eab_hmac_key`.
332
+
326
333
  The account key at `account_key_path` is loaded if present and created
327
334
  (mode 0600) otherwise.
328
335
 
@@ -419,6 +426,34 @@ acme = AcmeClient(
419
426
  )
420
427
  ```
421
428
 
429
+ ### Providers (Let's Encrypt, ZeroSSL)
430
+
431
+ `for_provider` resolves a named CA from `CA_DIRECTORIES`
432
+ (`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
433
+ remember directory URLs. Let's Encrypt stays the default everywhere else.
434
+
435
+ ```python
436
+ from devnomads.acme import AcmeClient
437
+
438
+ # Let's Encrypt (default CA) - no EAB needed.
439
+ acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
440
+
441
+ # ZeroSSL - requires External Account Binding credentials from your
442
+ # ZeroSSL account (Developer -> EAB Credentials, or the REST API).
443
+ acme = AcmeClient.for_provider(
444
+ "zerossl",
445
+ "/etc/devnomads/zerossl-account.key",
446
+ contact_email="ops@example.com",
447
+ eab_kid="...", # EAB key id
448
+ eab_hmac_key="...", # base64url EAB HMAC key
449
+ )
450
+ ```
451
+
452
+ Issuance is otherwise identical - the same `obtain_certificate` calls,
453
+ DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
454
+ directory URL and EAB fields can also be passed straight to the
455
+ `AcmeClient(...)` constructor if you prefer not to use the alias.
456
+
422
457
  ### Key helpers
423
458
 
424
459
  ```python
@@ -303,9 +303,16 @@ AcmeClient(
303
303
  preferred_chain=None, # match alternate chain by CN
304
304
  recursive_nameservers=None, # resolvers for propagation
305
305
  user_agent="devnomads",
306
+ eab_kid=None, # external account binding key id
307
+ eab_hmac_key=None, # external account binding HMAC key
308
+ eab_hmac_alg="HS256", # HS256/HS384/HS512
306
309
  )
307
310
  ```
308
311
 
312
+ Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
313
+ `directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
314
+ also need `eab_kid` and `eab_hmac_key`.
315
+
309
316
  The account key at `account_key_path` is loaded if present and created
310
317
  (mode 0600) otherwise.
311
318
 
@@ -402,6 +409,34 @@ acme = AcmeClient(
402
409
  )
403
410
  ```
404
411
 
412
+ ### Providers (Let's Encrypt, ZeroSSL)
413
+
414
+ `for_provider` resolves a named CA from `CA_DIRECTORIES`
415
+ (`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
416
+ remember directory URLs. Let's Encrypt stays the default everywhere else.
417
+
418
+ ```python
419
+ from devnomads.acme import AcmeClient
420
+
421
+ # Let's Encrypt (default CA) - no EAB needed.
422
+ acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
423
+
424
+ # ZeroSSL - requires External Account Binding credentials from your
425
+ # ZeroSSL account (Developer -> EAB Credentials, or the REST API).
426
+ acme = AcmeClient.for_provider(
427
+ "zerossl",
428
+ "/etc/devnomads/zerossl-account.key",
429
+ contact_email="ops@example.com",
430
+ eab_kid="...", # EAB key id
431
+ eab_hmac_key="...", # base64url EAB HMAC key
432
+ )
433
+ ```
434
+
435
+ Issuance is otherwise identical - the same `obtain_certificate` calls,
436
+ DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
437
+ directory URL and EAB fields can also be passed straight to the
438
+ `AcmeClient(...)` constructor if you prefer not to use the alias.
439
+
405
440
  ### Key helpers
406
441
 
407
442
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.2.4"
7
+ version = "0.2.5"
8
8
  description = "Python client library for the DevNomads API (transport, DNS, ACME)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,7 +17,14 @@ _EXTRA_MODULES = {"acme", "josepy", "cryptography", "dns"}
17
17
 
18
18
  try:
19
19
  from .challenge_server import ChallengeServer
20
- from .client import DEFAULT_DIRECTORY_URL, AcmeClient
20
+ from .client import (
21
+ CA_DIRECTORIES,
22
+ DEFAULT_DIRECTORY_URL,
23
+ LETSENCRYPT_DIRECTORY_URL,
24
+ LETSENCRYPT_STAGING_DIRECTORY_URL,
25
+ ZEROSSL_DIRECTORY_URL,
26
+ AcmeClient,
27
+ )
21
28
  from .dns01 import DevNomadsDnsProvider, DnsProvider
22
29
  from .errors import AcmeError
23
30
  from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
@@ -40,6 +47,10 @@ except ImportError as exc: # pragma: no cover - exercised via packaging
40
47
  __all__ = [
41
48
  "AcmeClient",
42
49
  "DEFAULT_DIRECTORY_URL",
50
+ "LETSENCRYPT_DIRECTORY_URL",
51
+ "LETSENCRYPT_STAGING_DIRECTORY_URL",
52
+ "ZEROSSL_DIRECTORY_URL",
53
+ "CA_DIRECTORIES",
43
54
  "AcmeError",
44
55
  "DnsProvider",
45
56
  "DevNomadsDnsProvider",
@@ -25,7 +25,25 @@ from .verify import verify_txt_record
25
25
 
26
26
  log = logging.getLogger("devnomads.acme")
27
27
 
28
- DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
28
+ LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
29
+ LETSENCRYPT_STAGING_DIRECTORY_URL = (
30
+ "https://acme-staging-v02.api.letsencrypt.org/directory"
31
+ )
32
+ ZEROSSL_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90"
33
+
34
+ # Let's Encrypt remains the default CA everywhere it is not specified.
35
+ DEFAULT_DIRECTORY_URL = LETSENCRYPT_DIRECTORY_URL
36
+
37
+ # Named ACME providers, resolvable via :meth:`AcmeClient.for_provider`.
38
+ CA_DIRECTORIES = {
39
+ "letsencrypt": LETSENCRYPT_DIRECTORY_URL,
40
+ "letsencrypt-staging": LETSENCRYPT_STAGING_DIRECTORY_URL,
41
+ "zerossl": ZEROSSL_DIRECTORY_URL,
42
+ }
43
+
44
+ # Providers that mandate External Account Binding (RFC 8555 §7.3.4) for new
45
+ # account registration.
46
+ _EAB_REQUIRED_PROVIDERS = frozenset({"zerossl"})
29
47
 
30
48
 
31
49
  def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
@@ -61,7 +79,14 @@ class AcmeClient:
61
79
  preferred_chain: str | None = None,
62
80
  recursive_nameservers: list[str] | None = None,
63
81
  user_agent: str = "devnomads",
82
+ eab_kid: str | None = None,
83
+ eab_hmac_key: str | None = None,
84
+ eab_hmac_alg: str = "HS256",
64
85
  ) -> None:
86
+ if bool(eab_kid) != bool(eab_hmac_key):
87
+ raise AcmeError(
88
+ "external account binding requires both eab_kid and eab_hmac_key"
89
+ )
65
90
  self.account_key_path = account_key_path
66
91
  self.directory_url = directory_url
67
92
  self.account_key_algorithm = account_key_algorithm
@@ -69,6 +94,39 @@ class AcmeClient:
69
94
  self.preferred_chain = preferred_chain
70
95
  self.recursive_nameservers = recursive_nameservers
71
96
  self.user_agent = user_agent
97
+ self.eab_kid = eab_kid
98
+ self.eab_hmac_key = eab_hmac_key
99
+ self.eab_hmac_alg = eab_hmac_alg
100
+
101
+ @classmethod
102
+ def for_provider(
103
+ cls, provider: str, account_key_path: str, **kwargs
104
+ ) -> "AcmeClient":
105
+ """Build a client for a named CA from :data:`CA_DIRECTORIES`.
106
+
107
+ ``provider`` is one of ``"letsencrypt"`` (the default CA),
108
+ ``"letsencrypt-staging"`` or ``"zerossl"``. Providers that mandate
109
+ External Account Binding (e.g. ZeroSSL) require ``eab_kid`` and
110
+ ``eab_hmac_key`` keyword arguments. Any other keyword is forwarded to
111
+ :class:`AcmeClient`.
112
+ """
113
+
114
+ try:
115
+ directory_url = CA_DIRECTORIES[provider]
116
+ except KeyError:
117
+ raise AcmeError(
118
+ f"unknown ACME provider {provider!r}; "
119
+ f"known providers: {', '.join(sorted(CA_DIRECTORIES))}"
120
+ ) from None
121
+ if provider in _EAB_REQUIRED_PROVIDERS and not (
122
+ kwargs.get("eab_kid") and kwargs.get("eab_hmac_key")
123
+ ):
124
+ raise AcmeError(
125
+ f"provider {provider!r} requires external account binding; "
126
+ "pass eab_kid and eab_hmac_key"
127
+ )
128
+ kwargs.pop("directory_url", None)
129
+ return cls(account_key_path, directory_url=directory_url, **kwargs)
72
130
 
73
131
  # -- account / client setup -------------------------------------------
74
132
 
@@ -84,11 +142,23 @@ class AcmeClient:
84
142
  directory = messages.Directory.from_json(net.get(self.directory_url).json())
85
143
  client = acme_client.ClientV2(directory, net)
86
144
 
87
- new_account = messages.NewRegistration(
145
+ registration_kwargs = dict(
88
146
  contact=(f"mailto:{self.contact_email}",) if self.contact_email else (),
89
147
  terms_of_service_agreed=True,
90
148
  only_return_existing=False,
91
149
  )
150
+ if self.eab_kid and self.eab_hmac_key:
151
+ log.debug("attaching external account binding (kid=%s)", self.eab_kid)
152
+ registration_kwargs["external_account_binding"] = (
153
+ messages.ExternalAccountBinding.from_data(
154
+ account_key.public_key(),
155
+ self.eab_kid,
156
+ self.eab_hmac_key,
157
+ directory,
158
+ hmac_alg=self.eab_hmac_alg,
159
+ )
160
+ )
161
+ new_account = messages.NewRegistration(**registration_kwargs)
92
162
 
93
163
  account = None
94
164
  try:
@@ -122,4 +122,108 @@ def test_init_client_registers_with_new_registration(monkeypatch, tmp_path):
122
122
  registration = captured["registration"]
123
123
  assert isinstance(registration, messages.NewRegistration)
124
124
  assert registration.contact == ("mailto:ops@example.com",)
125
+ # without EAB credentials, no external account binding is attached
126
+ assert not registration.external_account_binding
125
127
  assert client.net.account is not None
128
+
129
+
130
+ def _patch_fake_network(monkeypatch, client_mod, account_key, captured, directory):
131
+ from types import SimpleNamespace
132
+
133
+ class FakeResponse:
134
+ def json(self):
135
+ return {}
136
+
137
+ class FakeNetwork:
138
+ def __init__(self, *args, **kwargs):
139
+ self.account = None
140
+
141
+ def get(self, url):
142
+ return FakeResponse()
143
+
144
+ class FakeClientV2:
145
+ def __init__(self, directory, net):
146
+ self.net = net
147
+
148
+ def new_account(self, registration):
149
+ captured["registration"] = registration
150
+ return SimpleNamespace(uri="https://acme.example/acct/1")
151
+
152
+ monkeypatch.setattr(
153
+ client_mod, "load_or_create_account_key", lambda *a, **k: account_key
154
+ )
155
+ monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
156
+ monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
157
+ monkeypatch.setattr(
158
+ client_mod.messages.Directory, "from_json", lambda data: directory
159
+ )
160
+
161
+
162
+ def test_init_client_attaches_external_account_binding(monkeypatch, tmp_path):
163
+ """When EAB credentials are supplied, _init_client must attach an
164
+ externalAccountBinding to the NewRegistration (required by ZeroSSL)."""
165
+
166
+ from acme import messages
167
+ from cryptography.hazmat.backends import default_backend
168
+ from cryptography.hazmat.primitives.asymmetric import ec
169
+ from josepy import jwk
170
+
171
+ from devnomads.acme import client as client_mod
172
+
173
+ captured = {}
174
+ account_key = jwk.JWKEC(
175
+ key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
176
+ )
177
+ directory = messages.Directory.from_json(
178
+ {"newAccount": "https://acme.example/new-acct"}
179
+ )
180
+ _patch_fake_network(monkeypatch, client_mod, account_key, captured, directory)
181
+
182
+ ac = client_mod.AcmeClient(
183
+ str(tmp_path / "account.pem"),
184
+ directory_url=client_mod.ZEROSSL_DIRECTORY_URL,
185
+ eab_kid="kid-123",
186
+ # HMAC key must be valid base64; this is "hmac-secret" encoded.
187
+ eab_hmac_key="aG1hYy1zZWNyZXQ",
188
+ )
189
+ ac._init_client()
190
+
191
+ registration = captured["registration"]
192
+ assert registration.external_account_binding # signed JWS dict
193
+
194
+
195
+ def test_for_provider_resolves_directory_urls(tmp_path):
196
+ from devnomads.acme import client as client_mod
197
+
198
+ le = client_mod.AcmeClient.for_provider("letsencrypt", str(tmp_path / "a.pem"))
199
+ assert le.directory_url == client_mod.LETSENCRYPT_DIRECTORY_URL
200
+
201
+ zs = client_mod.AcmeClient.for_provider(
202
+ "zerossl",
203
+ str(tmp_path / "b.pem"),
204
+ eab_kid="kid-123",
205
+ eab_hmac_key="aG1hYy1zZWNyZXQ",
206
+ )
207
+ assert zs.directory_url == client_mod.ZEROSSL_DIRECTORY_URL
208
+ assert zs.eab_kid == "kid-123"
209
+
210
+
211
+ def test_for_provider_unknown_raises(tmp_path):
212
+ from devnomads.acme import client as client_mod
213
+
214
+ with pytest.raises(AcmeError):
215
+ client_mod.AcmeClient.for_provider("notaca", str(tmp_path / "a.pem"))
216
+
217
+
218
+ def test_for_provider_zerossl_requires_eab(tmp_path):
219
+ from devnomads.acme import client as client_mod
220
+
221
+ with pytest.raises(AcmeError):
222
+ client_mod.AcmeClient.for_provider("zerossl", str(tmp_path / "a.pem"))
223
+
224
+
225
+ def test_partial_eab_credentials_raise(tmp_path):
226
+ from devnomads.acme import client as client_mod
227
+
228
+ with pytest.raises(AcmeError):
229
+ client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
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