certapi 0.1.0__tar.gz → 0.2.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 (25) hide show
  1. {certapi-0.1.0 → certapi-0.2.0}/PKG-INFO +6 -8
  2. {certapi-0.1.0 → certapi-0.2.0}/README.md +4 -4
  3. {certapi-0.1.0 → certapi-0.2.0}/setup.py +2 -2
  4. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/Acme.py +71 -39
  5. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/__init__.py +1 -0
  6. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/certauthority.py +68 -16
  7. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/challenge.py +13 -31
  8. certapi-0.2.0/src/certapi/cloudflare_challenge_store.py +57 -0
  9. certapi-0.2.0/src/certapi/cloudflare_client.py +137 -0
  10. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/crypto.py +20 -1
  11. certapi-0.2.0/src/certapi/crypto_classes.py +240 -0
  12. certapi-0.2.0/src/certapi/custom_certauthority.py +127 -0
  13. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/db.py +98 -96
  14. {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/PKG-INFO +6 -8
  15. {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/SOURCES.txt +3 -2
  16. certapi-0.1.0/src/certapi/crypto_classes.py +0 -135
  17. certapi-0.1.0/src/certapi/custom_certauthority.py +0 -94
  18. certapi-0.1.0/tests/test_custom_certauthority.py +0 -43
  19. {certapi-0.1.0 → certapi-0.2.0}/MANIFEST.in +0 -0
  20. {certapi-0.1.0 → certapi-0.2.0}/pyproject.toml +0 -0
  21. {certapi-0.1.0 → certapi-0.2.0}/setup.cfg +0 -0
  22. {certapi-0.1.0 → certapi-0.2.0}/src/certapi/util.py +0 -0
  23. {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/dependency_links.txt +0 -0
  24. {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/requires.txt +0 -0
  25. {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: certapi
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
- Home-page: https://github.com/mesudip/certmanager
5
+ Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
7
7
  Author-email: sudipbhattarai100@gmail.com
8
8
  Classifier: Programming Language :: Python :: 3
@@ -10,17 +10,15 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.6
12
12
  Description-Content-Type: text/markdown
13
- Requires-Dist: cryptography
14
- Requires-Dist: requests
15
13
 
16
- # CertManager
14
+ # CertApi
17
15
 
18
- CertManager is a Python package for requesting SSL certificates from ACME.
16
+ CertApi is a Python package for requesting SSL certificates from ACME.
19
17
  This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
20
18
 
21
19
  ## Installation
22
20
 
23
- You can install CertManager using pip:
21
+ You can install CertApi using pip:
24
22
 
25
23
  ```bash
26
24
  pip install certapi
@@ -30,7 +28,7 @@ pip install certapi
30
28
 
31
29
  ```python
32
30
  import json
33
- from certmanager import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
31
+ from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
34
32
 
35
33
  key_store = FilesystemKeyStore("data")
36
34
  challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
@@ -1,11 +1,11 @@
1
- # CertManager
1
+ # CertApi
2
2
 
3
- CertManager is a Python package for requesting SSL certificates from ACME.
3
+ CertApi is a Python package for requesting SSL certificates from ACME.
4
4
  This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
5
5
 
6
6
  ## Installation
7
7
 
8
- You can install CertManager using pip:
8
+ You can install CertApi using pip:
9
9
 
10
10
  ```bash
11
11
  pip install certapi
@@ -15,7 +15,7 @@ pip install certapi
15
15
 
16
16
  ```python
17
17
  import json
18
- from certmanager import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
18
+ from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
19
19
 
20
20
  key_store = FilesystemKeyStore("data")
21
21
  challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.1.0",
5
+ version="0.2.0",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -15,7 +15,7 @@ setup(
15
15
  long_description_content_type="text/markdown",
16
16
  author="Sudip Bhattarai",
17
17
  author_email="sudipbhattarai100@gmail.com",
18
- url="https://github.com/mesudip/certmanager",
18
+ url="https://github.com/mesudip/certapi",
19
19
  classifiers=[
20
20
  "Programming Language :: Python :: 3",
21
21
  "License :: OSI Approved :: MIT License",
@@ -8,19 +8,20 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
8
8
  from cryptography.x509 import CertificateSigningRequest, Certificate
9
9
  from . import crypto
10
10
  import requests
11
- from .crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name
11
+ from .crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name, sign_for_jws
12
12
  from .util import b64_encode, b64_string
13
13
 
14
- # acme_url = os.environ.get("LETSENCRYPT_API", "https://acme-staging-v02.api.letsencrypt.org/directory")
15
- acme_url = os.environ.get("LETSENCRYPT_API", "https://acme-v02.api.letsencrypt.org/directory")
14
+ acme_url = os.environ.get("LETSENCRYPT_API", "https://acme-staging-v02.api.letsencrypt.org/directory")
15
+ # acme_url = os.environ.get("LETSENCRYPT_API", None)
16
16
 
17
17
 
18
18
  class AcmeError(Exception):
19
19
  def __init__(self, message, detail, step):
20
- super().__init__(step,message)
20
+ super().__init__(step, message)
21
21
  self.message: str = message
22
22
  self.step: str = step
23
23
  self.detail: dict = detail
24
+ self.can_retry = False
24
25
 
25
26
  def json_obj(self) -> dict:
26
27
  return {"message": self.message, "step": self.step, "detail": self.detail}
@@ -36,6 +37,7 @@ class AcmeNetworkError(AcmeError, requests.RequestException):
36
37
  requests.RequestException.__init__(self, request=request)
37
38
  AcmeError.__init__(self, message, detail, step)
38
39
  requests.RequestException.__init__(self, request=request) # Pass message to RequestException
40
+ self.can_retry = True
39
41
 
40
42
 
41
43
  class AcmeHttpError(AcmeError, requests.HTTPError):
@@ -45,7 +47,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
45
47
 
46
48
  def __init__(self, response: requests.Response, step: str):
47
49
  requests.HTTPError.__init__(self, response=response)
48
- self.response=response
50
+ self.response = response
49
51
  (message, detail) = self.extract_acme_response_error()
50
52
  AcmeError.__init__(self, message, detail, step)
51
53
 
@@ -121,8 +123,8 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
121
123
  message = err_detail
122
124
 
123
125
  if message is None:
124
- if res_json.get('detail'):
125
- message = res_json['detail']
126
+ if res_json.get("detail"):
127
+ message = res_json["detail"]
126
128
  else:
127
129
  message = "Received status=" + str(self.response.status_code) + " from AMCE server"
128
130
  if error is None:
@@ -141,22 +143,28 @@ class AcmeInvaliOrderError(AcmeHttpError):
141
143
  super().__init__(response, step)
142
144
 
143
145
 
146
+ class AcmeInvaliNonceError(AcmeHttpError):
147
+ def __init__(self, response: requests.Response, step: str):
148
+ super().__init__(response, step)
149
+ self.can_retry = True
150
+
151
+
144
152
  def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
145
- res=None
153
+ res = None
146
154
  try:
147
155
  res = requests.request(method, url, json=json, headers=headers, timeout=15)
148
- print("Request ["+str(res.status_code)+"] : " + method + " " + url + " step=" + step)
156
+ print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + step)
149
157
  except requests.HTTPError as e:
150
158
  status = res.status_code if res else None
151
159
  status = status if status else (e.response.status_code if e.response else None)
152
160
  if status:
153
- print("Request ["+str(status)+"] : " + method + " " + url + " step=" + step)
161
+ print("Request [" + str(status) + "] : " + method + " " + url + " step=" + step)
154
162
  else:
155
163
  print("Request : " + method + " " + url + " step=" + step)
156
164
 
157
165
  raise e
158
166
  except requests.RequestException as e:
159
- print("Request : " + method + " " + url + " step=" + step)
167
+ print("Request : " + str(method) + " " + str(url) + " step=" + str(step))
160
168
  raise AcmeNetworkError(
161
169
  e.request,
162
170
  f"Error communicating with ACME server",
@@ -169,6 +177,19 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
169
177
  step,
170
178
  )
171
179
  if 199 <= res.status_code > 299:
180
+
181
+ [print(x, y) for (x, y) in res.headers.items()]
182
+ print("Response:", res.text)
183
+ json_data = None
184
+ try:
185
+ json_data = res.json()
186
+ except requests.RequestException as e:
187
+ pass
188
+ if json_data and json_data.get("type"):
189
+ errorType = json_data["type"]
190
+ if errorType == "urn:ietf:params:acme:error:badNonce":
191
+ raise AcmeInvaliNonceError(res, step=step)
192
+
172
193
  if throw:
173
194
  raise AcmeHttpError(res, step=step)
174
195
  return res
@@ -191,8 +212,9 @@ class Acme:
191
212
  self.account_key = account_key
192
213
  # json web key format for public key
193
214
  self.jwk = jwk(self.account_key)
215
+ print(self.jwk)
194
216
  self.nonce = []
195
- self.acme_url = url
217
+ self.acme_url = url if url else self.URL_STAGING
196
218
  self.key_id = None
197
219
  self.directory = None
198
220
  self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
@@ -210,19 +232,12 @@ class Acme:
210
232
  url = self._directory(path_name)
211
233
  return self._signed_req(url, payload, depth, step="Acme request:" + path_name)
212
234
 
213
- def _signed_req(
214
- self, url, payload: Union[str, dict, list, bytes, None] = None, depth=0, step="Acme Request", throw=True
215
- ) -> requests.Response:
216
- payload64 = b64_encode(payload) if payload is not None else b""
235
+ def get_nonce(self, step: str, counter=1):
217
236
  nonce = None
218
- with self._nonce_lock: # Acquire lock to ensure thread-safe access to nonce
219
- # Check if there are any nonces available
237
+ with self._nonce_lock: # Acquire nonce
220
238
  if self.nonce:
221
- # Pop the first nonce from the list
222
239
  nonce = self.nonce.pop(0)
223
-
224
- # Fetch a new nonce if the list is empty
225
- nonce = (
240
+ return (
226
241
  nonce
227
242
  if nonce
228
243
  else get(
@@ -230,10 +245,20 @@ class Acme:
230
245
  ).headers.get("Replay-Nonce")
231
246
  )
232
247
 
248
+ def record_nonce(self, response: requests.Response) -> requests.Response:
249
+ with self._nonce_lock:
250
+ self.nonce.append(response.headers.get("Replay-Nonce", None))
251
+ return response
252
+
253
+ def _signed_req(
254
+ self, url, payload: Union[str, dict, list, bytes, None] = None, depth=0, step="Acme Request", throw=True
255
+ ) -> requests.Response:
256
+ payload64 = b64_encode(payload) if payload is not None else b""
257
+
233
258
  protected = {
234
259
  "url": url,
235
260
  "alg": get_algorithm_name(self.account_key),
236
- "nonce": nonce,
261
+ "nonce": self.get_nonce(step),
237
262
  }
238
263
 
239
264
  if self.key_id:
@@ -244,21 +269,18 @@ class Acme:
244
269
  payload = {
245
270
  "protected": protectedb64.decode("utf-8"),
246
271
  "payload": payload64.decode("utf-8"),
247
- "signature": b64_string(sign(self.account_key, b".".join([protectedb64, payload64]))),
272
+ "signature": b64_string(sign_for_jws(self.account_key, b".".join([protectedb64, payload64]))),
248
273
  }
274
+ try:
249
275
 
250
- response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
251
- if response.status_code > 299 or response.status_code < 200 :
252
- print("-" * 30 + " Request " + "-" * 30)
253
- print(response.status_code, " : ", url)
254
- print("status:", response.status_code)
255
- print(json.dumps({x[0]: x[1] for x in response.headers.items()}, indent=2))
256
- print(response.text)
257
- print("-" * 60)
276
+ response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
277
+ except AcmeError as e:
278
+ if e.can_retry and depth <= 0:
279
+ return self._signed_req(url, payload, depth + 1, step, throw)
280
+ else:
281
+ raise e
258
282
 
259
- with self._nonce_lock:
260
- self.nonce.append(response.headers.get("Replay-Nonce", None))
261
- return response
283
+ return self.record_nonce(response)
262
284
 
263
285
  def register(self):
264
286
  response = self._directory_req("newAccount", {"termsOfServiceAgreed": True})
@@ -337,6 +359,7 @@ class Challenge:
337
359
  challenge = self.get_challenge()
338
360
  self.token = challenge["token"]
339
361
  self.verified = challenge["status"] == "valid"
362
+ self.domain = data["identifier"]["value"] # Add domain attribute
340
363
 
341
364
  jwk_json = json.dumps(self._acme.jwk, sort_keys=True, separators=(",", ":"))
342
365
  thumbprint = b64_encode(digest_sha256(jwk_json.encode("utf8")))
@@ -344,9 +367,11 @@ class Challenge:
344
367
 
345
368
  self.url = "http://{0}/.well-known/acme-challenge/{1}".format(data["identifier"]["value"], self.token)
346
369
 
347
- def verify(self) -> bool:
370
+ def verify(self, dns=False) -> bool:
348
371
  if not self.verified:
349
- response = self._acme._signed_req(self.get_challenge()["url"], {}, step="Verify Challenge", throw=False)
372
+ response = self._acme._signed_req(
373
+ self.get_challenge(key="dns-01" if dns else "http-01")["url"], {}, step="Verify Challenge", throw=False
374
+ )
350
375
  if response.status_code == 200 and response.json()["status"] == "valid":
351
376
  self.verified = True
352
377
  return True
@@ -378,9 +403,16 @@ class Challenge:
378
403
  return False
379
404
 
380
405
  def get_challenge(self, key="http-01"):
381
- for method in self._data["challenges"]:
406
+ challenges = self._data["challenges"]
407
+ for method in challenges:
382
408
  if method["type"] == key:
383
409
  return method
410
+ if len(challenges) == 1:
411
+ return challenges[0]
412
+
413
+ ch_types = [x["type"] for x in self._data["challenges"]]
384
414
  raise AcmeError(
385
- "'http-01' not found in challenges", {"response": self._data["challenges"]}, "Acme Challenge Verification"
415
+ f"'{key}' not found in challenges. available:{str(ch_types)}",
416
+ {"response": self._data["challenges"]},
417
+ "Acme Challenge Verification",
386
418
  )
@@ -1,5 +1,6 @@
1
1
  from .Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
2
2
  from .certauthority import CertAuthority
3
+ from .custom_certauthority import CertificateIssuer
3
4
  from .crypto import gen_key_ed25519, create_csr
4
5
  from .db import KeyStore, FilesystemKeyStore, SqliteKeyStore, PostgresKeyStore
5
6
  from .challenge import InMemoryChallengeStore, FileSystemChallengeStore
@@ -6,19 +6,35 @@ import requests
6
6
  from cryptography.x509 import Certificate
7
7
  from requests import Response
8
8
 
9
+ from typing import List, Union, Callable, Tuple, Dict
10
+ import json
11
+ import time
12
+
13
+ import requests
14
+ from cryptography.x509 import Certificate
15
+ from requests import Response
16
+
9
17
  from . import Acme, Challenge, Order
10
18
  from . import crypto
11
19
  from . import challenge
12
- from .crypto import cert_to_pem, key_to_pem
20
+ from .crypto import cert_to_pem, key_to_pem, digest_sha256
13
21
  from .crypto_classes import Key
14
22
  from .db import KeyStore
23
+ from .util import b64_string
15
24
 
16
25
 
17
26
  class CertAuthority:
18
- def __init__(self, challenge_store: challenge.ChallengeStore, key_store: KeyStore,acme_url=None):
19
- self.acme = Acme(key_store.account_key,url=acme_url)
27
+ def __init__(
28
+ self,
29
+ challenge_store: challenge.ChallengeStore,
30
+ key_store: KeyStore,
31
+ acme_url=None,
32
+ dns_stores: List[challenge.ChallengeStore] = None,
33
+ ):
34
+ self.acme = Acme(key_store.account_key, url=acme_url)
20
35
  self.key_store = key_store
21
36
  self.challengesStore: challenge.ChallengeStore = challenge_store
37
+ self.dns_stores = dns_stores if dns_stores is not None else []
22
38
 
23
39
  def setup(self):
24
40
  self.acme.setup()
@@ -28,27 +44,58 @@ class CertAuthority:
28
44
  elif res.status_code != 200:
29
45
  raise Exception("Acme registration didn't return 200 or 201 ", res.json())
30
46
 
31
- def obtainCert(
32
- self, host: Union[str, List[str]]
33
- ) -> Union[Tuple["CertificateResponse", None], Tuple[None, requests.Response]]:
47
+ def obtainCert(self, host: Union[str, List[str]]) -> "CertificateResponse":
34
48
  if type(host) == str:
35
49
  host = [host]
36
50
 
37
51
  existing = {c[0]: c[1] for c in [(h, self.key_store.get_cert(h)) for h in host] if c[1] is not None}
38
52
  missing = [h for h in host if h not in existing]
39
53
  if len(missing) > 0:
54
+ has_wildcard = False
55
+ # Determine which challenge store to use
56
+ challenge_store_to_use = self.challengesStore
57
+ for h in missing:
58
+ if h.startswith("*."): # Wildcard domain
59
+ has_wildcard = True
60
+ found_dns_store = False
61
+
62
+ for dns_store in self.dns_stores:
63
+ if dns_store.has_domain(h.lstrip("*.")): # Check if the DNS store can handle the base domain
64
+ challenge_store_to_use = dns_store
65
+ found_dns_store = True
66
+ break
67
+ if not found_dns_store:
68
+ raise Exception(f"No DNS challenge store found for wildcard domain {h}")
69
+ break # Assuming all domains in a single request will use the same challenge type
70
+
40
71
  private_key = crypto.gen_key_secp256r1()
41
72
  order = self.acme.create_authorized_order(missing)
42
73
 
43
74
  challenges = order.remaining_challenges()
75
+
44
76
  for c in challenges:
45
77
  print("[ Challenge ]", c.token, "=", c.authorization_key)
46
- self.challengesStore[c.token] = c.authorization_key
78
+ # For DNS-01 challenges, the key should be _acme-challenge.<domain>
79
+ challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
80
+
81
+ # For DNS-01 challenges, the value is the SHA256 hash of the authorization_key, base64url encoded
82
+ challenge_value = (
83
+ b64_string(digest_sha256(c.authorization_key.encode("utf8")))
84
+ if has_wildcard
85
+ else c.authorization_key
86
+ )
87
+
88
+ challenge_store_to_use.save_challenge(challenge_name, challenge_value, c.domain)
89
+
90
+ # Add an initial sleep to allow DNS propagation
91
+ if has_wildcard:
92
+ print("Waiting for DNS propagation (10 seconds)...")
93
+ time.sleep(10)
94
+
47
95
  for c in challenges:
48
96
  # c.self_verify()
49
- c.verify()
50
-
51
- end = time.time() + 40 # max 12 seconds
97
+ c.verify(dns=has_wildcard)
98
+ end = time.time() + 60 # Increase overall timeout
52
99
  source: List[Challenge] = [x for x in challenges]
53
100
  sink = []
54
101
  counter = 1
@@ -63,8 +110,6 @@ class CertAuthority:
63
110
  if len(sink) > 0:
64
111
  time.sleep(3)
65
112
  source, sink, counter = sink, [], counter + 1
66
- else:
67
- print("Order is already Ready.")
68
113
  csr = crypto.create_csr(private_key, missing[0], missing[1:])
69
114
  order.finalize(csr)
70
115
 
@@ -73,21 +118,28 @@ class CertAuthority:
73
118
  order.refresh() # is this refresh necessary?
74
119
 
75
120
  if order.status == "valid":
76
- (certificate, _) = order.get_certificate()
121
+ certificate = order.get_certificate()
77
122
  key_id = self.key_store.save_key(private_key, missing[0])
78
123
  cert_id = self.key_store.save_cert(key_id, certificate, missing)
79
124
  issued_cert = IssuedCert(key_to_pem(private_key), certificate, missing)
80
- response = createExistingResponse(existing, [issued_cert])
81
- return (response, None)
125
+ # Clean up challenges after successful certificate issuance
126
+ for c in challenges:
127
+ challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
128
+ challenge_store_to_use.delete_challenge(challenge_name, c.domain)
129
+ return createExistingResponse(existing, [issued_cert])
82
130
  elif order.status == "processing":
83
131
  if count == 0:
132
+ # Clean up challenges if timeout occurs
133
+ for c in challenges:
134
+ challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
135
+ challenge_store_to_use.delete_challenge(challenge_name, c.domain)
84
136
  return None
85
137
  return obtain_cert()
86
138
  return None
87
139
 
88
140
  return obtain_cert()
89
141
  else:
90
- return createExistingResponse(existing, []), None
142
+ return createExistingResponse(existing, [])
91
143
 
92
144
 
93
145
  def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate]], issued_certs: List["IssuedCert"]):
@@ -2,28 +2,19 @@ import os
2
2
  from collections.abc import MutableMapping
3
3
 
4
4
 
5
- class ChallengeStore(MutableMapping):
5
+ class ChallengeStore:
6
6
  """
7
7
  Abstract base class for a challenge store.
8
- Provides dictionary-like behavior by inheriting from MutableMapping.
9
8
  """
10
9
 
11
- def __setitem__(self, key, value):
12
- self.save_challenge(key, value)
13
-
14
- def __getitem__(self, key):
15
- value = self.get_challenge(key)
16
- if value is None:
17
- raise KeyError(key)
18
- return value
10
+ def save_challenge(self, key: str, value: str, domain: str = None):
11
+ raise NotImplementedError("Must implement `save_challenge` method.")
19
12
 
20
- def __delitem__(self, key):
21
- if key not in self:
22
- raise KeyError(key)
23
- self.delete_challenge(key)
13
+ def get_challenge(self, key: str, domain: str = None) -> str:
14
+ raise NotImplementedError("Must implement `get_challenge` method.")
24
15
 
25
- def __contains__(self, key):
26
- return self.get_challenge(key) is not None
16
+ def delete_challenge(self, key: str, domain: str = None):
17
+ raise NotImplementedError("Must implement `delete_challenge` method.")
27
18
 
28
19
  def __iter__(self):
29
20
  raise NotImplementedError("Must implement `__iter__` method.")
@@ -31,15 +22,6 @@ class ChallengeStore(MutableMapping):
31
22
  def __len__(self):
32
23
  raise NotImplementedError("Must implement `__len__` method.")
33
24
 
34
- def save_challenge(self, key: str, value: str):
35
- raise NotImplementedError("Must implement `save_challenge` method.")
36
-
37
- def get_challenge(self, key: str) -> str:
38
- raise NotImplementedError("Must implement `get_challenge` method.")
39
-
40
- def delete_challenge(self, key: str):
41
- raise NotImplementedError("Must implement `delete_challenge` method.")
42
-
43
25
 
44
26
  class InMemoryChallengeStore(ChallengeStore):
45
27
  """
@@ -49,13 +31,13 @@ class InMemoryChallengeStore(ChallengeStore):
49
31
  def __init__(self):
50
32
  self.challenges = {}
51
33
 
52
- def save_challenge(self, key: str, value: str):
34
+ def save_challenge(self, key: str, value: str, domain: str = None):
53
35
  self.challenges[key] = value
54
36
 
55
- def get_challenge(self, key: str) -> str:
37
+ def get_challenge(self, key: str, domain: str = None) -> str:
56
38
  return self.challenges.get(key, "")
57
39
 
58
- def delete_challenge(self, key: str):
40
+ def delete_challenge(self, key: str, domain: str = None):
59
41
  if key in self.challenges:
60
42
  del self.challenges[key]
61
43
 
@@ -75,19 +57,19 @@ class FileSystemChallengeStore(ChallengeStore):
75
57
  self.directory = directory
76
58
  os.makedirs(self.directory, exist_ok=True)
77
59
 
78
- def save_challenge(self, key: str, value: str):
60
+ def save_challenge(self, key: str, value: str, domain: str = None):
79
61
  file_path = os.path.join(self.directory, key)
80
62
  with open(file_path, "w") as file:
81
63
  file.write(value)
82
64
 
83
- def get_challenge(self, key: str) -> str:
65
+ def get_challenge(self, key: str, domain: str = None) -> str:
84
66
  file_path = os.path.join(self.directory, key)
85
67
  if not os.path.exists(file_path):
86
68
  return None
87
69
  with open(file_path, "r") as file:
88
70
  return file.read()
89
71
 
90
- def delete_challenge(self, key: str):
72
+ def delete_challenge(self, key: str, domain: str = None):
91
73
  file_path = os.path.join(self.directory, key)
92
74
  if os.path.exists(file_path):
93
75
  os.remove(file_path)
@@ -0,0 +1,57 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from certapi.challenge import ChallengeStore
4
+ from certapi.cloudflare_client import Cloudflare
5
+
6
+
7
+ class CloudflareChallengeStore(ChallengeStore):
8
+ def __init__(self):
9
+ self.cloudflare = Cloudflare()
10
+ self.challenges_map = {} # Stores key: record_id (still needed for deletion)
11
+
12
+ def has_domain(self, domain: str) -> bool:
13
+ """
14
+ Checks if the Cloudflare account has access to the given domain (or its base domain)
15
+ as a registered zone.
16
+ """
17
+ try:
18
+ self.cloudflare.determine_registered_domain(domain)
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+ def save_challenge(self, key: str, value: str, domain=None):
24
+ # key example: _acme-challenge.sub.example.com
25
+ # value example: ACME_CHALLENGE_TOKEN
26
+ base_domain = self.cloudflare.determine_registered_domain(domain)
27
+
28
+ record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
29
+ self.challenges_map[key] = record_id
30
+ print(f"CloudflareChallengeStore: Saved challenge for {key} with record ID {record_id}")
31
+
32
+ def get_challenge(self, key: str, domain: str) -> str:
33
+ base_domain = self.cloudflare.determine_registered_domain(domain)
34
+ records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
35
+ for record in records:
36
+ if record["name"] == key:
37
+ return record["content"]
38
+ return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
39
+
40
+ def delete_challenge(self, key: str, domain: str):
41
+ if key not in self.challenges_map:
42
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
43
+
44
+ record_id = self.challenges_map[key]
45
+ base_domain = self.cloudflare.determine_registered_domain(domain)
46
+ self.cloudflare.delete_record(record=record_id, domain=base_domain)
47
+ del self.challenges_map[key]
48
+ print(f"CloudflareChallengeStore: Deleted challenge for {key} with record ID {record_id}")
49
+
50
+ def __iter__(self):
51
+ # This is tricky as we can't easily iterate all challenges across all domains
52
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
53
+ return iter(self.challenges_map)
54
+
55
+ def __len__(self):
56
+ # Similar to __iter__, this will count challenges managed by this store instance.
57
+ return len(self.challenges_map)