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.
- {certapi-0.1.0 → certapi-0.2.0}/PKG-INFO +6 -8
- {certapi-0.1.0 → certapi-0.2.0}/README.md +4 -4
- {certapi-0.1.0 → certapi-0.2.0}/setup.py +2 -2
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/Acme.py +71 -39
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/__init__.py +1 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/certauthority.py +68 -16
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/challenge.py +13 -31
- certapi-0.2.0/src/certapi/cloudflare_challenge_store.py +57 -0
- certapi-0.2.0/src/certapi/cloudflare_client.py +137 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/crypto.py +20 -1
- certapi-0.2.0/src/certapi/crypto_classes.py +240 -0
- certapi-0.2.0/src/certapi/custom_certauthority.py +127 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/db.py +98 -96
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/PKG-INFO +6 -8
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/SOURCES.txt +3 -2
- certapi-0.1.0/src/certapi/crypto_classes.py +0 -135
- certapi-0.1.0/src/certapi/custom_certauthority.py +0 -94
- certapi-0.1.0/tests/test_custom_certauthority.py +0 -43
- {certapi-0.1.0 → certapi-0.2.0}/MANIFEST.in +0 -0
- {certapi-0.1.0 → certapi-0.2.0}/pyproject.toml +0 -0
- {certapi-0.1.0 → certapi-0.2.0}/setup.cfg +0 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi/util.py +0 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.1.0 → certapi-0.2.0}/src/certapi.egg-info/requires.txt +0 -0
- {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.
|
|
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/
|
|
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
|
-
#
|
|
14
|
+
# CertApi
|
|
17
15
|
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
1
|
+
# CertApi
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
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.
|
|
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/
|
|
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
|
-
|
|
15
|
-
acme_url = os.environ.get("LETSENCRYPT_API",
|
|
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(
|
|
125
|
-
message = res_json[
|
|
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
|
|
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
|
|
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":
|
|
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(
|
|
272
|
+
"signature": b64_string(sign_for_jws(self.account_key, b".".join([protectedb64, payload64]))),
|
|
248
273
|
}
|
|
274
|
+
try:
|
|
249
275
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
"'
|
|
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__(
|
|
19
|
-
self
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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, [])
|
|
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
|
|
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
|
|
12
|
-
|
|
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
|
|
21
|
-
|
|
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
|
|
26
|
-
|
|
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)
|