certapi 0.1.0__py3-none-any.whl
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/Acme.py +386 -0
- certapi/__init__.py +5 -0
- certapi/certauthority.py +154 -0
- certapi/challenge.py +121 -0
- certapi/crypto.py +222 -0
- certapi/crypto_classes.py +135 -0
- certapi/custom_certauthority.py +94 -0
- certapi/db.py +365 -0
- certapi/util.py +19 -0
- certapi-0.1.0.dist-info/METADATA +45 -0
- certapi-0.1.0.dist-info/RECORD +13 -0
- certapi-0.1.0.dist-info/WHEEL +5 -0
- certapi-0.1.0.dist-info/top_level.txt +1 -0
certapi/Acme.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Union, List, Tuple
|
|
5
|
+
import json
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
8
|
+
from cryptography.x509 import CertificateSigningRequest, Certificate
|
|
9
|
+
from . import crypto
|
|
10
|
+
import requests
|
|
11
|
+
from .crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name
|
|
12
|
+
from .util import b64_encode, b64_string
|
|
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")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AcmeError(Exception):
|
|
19
|
+
def __init__(self, message, detail, step):
|
|
20
|
+
super().__init__(step,message)
|
|
21
|
+
self.message: str = message
|
|
22
|
+
self.step: str = step
|
|
23
|
+
self.detail: dict = detail
|
|
24
|
+
|
|
25
|
+
def json_obj(self) -> dict:
|
|
26
|
+
return {"message": self.message, "step": self.step, "detail": self.detail}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AcmeNetworkError(AcmeError, requests.RequestException):
|
|
30
|
+
"""
|
|
31
|
+
There was an error connecting/communicating with the AcmeServer.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, request: requests.Request, message, detail, step):
|
|
35
|
+
# Initialize both parent classes
|
|
36
|
+
requests.RequestException.__init__(self, request=request)
|
|
37
|
+
AcmeError.__init__(self, message, detail, step)
|
|
38
|
+
requests.RequestException.__init__(self, request=request) # Pass message to RequestException
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
42
|
+
"""
|
|
43
|
+
Acme Server replied with error status.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, response: requests.Response, step: str):
|
|
47
|
+
requests.HTTPError.__init__(self, response=response)
|
|
48
|
+
self.response=response
|
|
49
|
+
(message, detail) = self.extract_acme_response_error()
|
|
50
|
+
AcmeError.__init__(self, message, detail, step)
|
|
51
|
+
|
|
52
|
+
def extract_acme_response_error(self):
|
|
53
|
+
message = None
|
|
54
|
+
error = None
|
|
55
|
+
try:
|
|
56
|
+
res_json = self.response.json()
|
|
57
|
+
if res_json["status"] == "invalid":
|
|
58
|
+
if res_json["challenges"]:
|
|
59
|
+
failed_challenge: dict = [x for x in res_json["challenges"] if x["status"] == "invalid"][0]
|
|
60
|
+
error = failed_challenge["error"]
|
|
61
|
+
err_detail: str = error.get("detail")
|
|
62
|
+
validation_record: dict = failed_challenge.get("validationRecord")
|
|
63
|
+
error = {
|
|
64
|
+
"url": failed_challenge["url"],
|
|
65
|
+
}
|
|
66
|
+
if validation_record is None:
|
|
67
|
+
# Search for the pattern and extract the content
|
|
68
|
+
if err_detail.startswith("DNS problem: NXDOMAIN"):
|
|
69
|
+
error["dns"] = {"error": "DNS record doesn't exist"}
|
|
70
|
+
error["hostname"] = re.findall(r"looking up [A-Z]+ for ([\w.-]+)", err_detail)[0]
|
|
71
|
+
message = error["hostname"] + " doesn't have a valid DNS record"
|
|
72
|
+
else:
|
|
73
|
+
error["dns"] = {"error": err_detail}
|
|
74
|
+
message = err_detail
|
|
75
|
+
else:
|
|
76
|
+
validation_record = validation_record[0]
|
|
77
|
+
error["hostname"] = validation_record["hostname"]
|
|
78
|
+
error["dns"] = {
|
|
79
|
+
"resolved": validation_record["addressesResolved"],
|
|
80
|
+
"used": validation_record["addressUsed"],
|
|
81
|
+
}
|
|
82
|
+
err_detail: str = error["detail"]
|
|
83
|
+
if error["type"] == "urn:ietf:params:acme:error:connection":
|
|
84
|
+
if "Timeout during connect" in err_detail:
|
|
85
|
+
error["connect"] = {"error": "Timeout"}
|
|
86
|
+
message = (
|
|
87
|
+
error["hostname"]
|
|
88
|
+
+ "["
|
|
89
|
+
+ validation_record["addressUsed"]
|
|
90
|
+
+ ":"
|
|
91
|
+
+ validation_record["port"]
|
|
92
|
+
+ "] Connect Timeout (Maybe firewall reasons)"
|
|
93
|
+
)
|
|
94
|
+
elif err_detail.endswith("Connection refused"):
|
|
95
|
+
error["connect"] = {"error": "connection refused"}
|
|
96
|
+
message = (
|
|
97
|
+
error["hostname"]
|
|
98
|
+
+ "["
|
|
99
|
+
+ validation_record["addressUsed"]
|
|
100
|
+
+ ":"
|
|
101
|
+
+ validation_record["port"]
|
|
102
|
+
+ "] Connection Refused (Is http server running?)"
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
message = err_detail
|
|
106
|
+
else:
|
|
107
|
+
pattern = r'Invalid response from .*?: "(.*)"'
|
|
108
|
+
|
|
109
|
+
match = re.search(pattern, err_detail)
|
|
110
|
+
|
|
111
|
+
if match:
|
|
112
|
+
error["response"] = (match.group(1) if match is not None else err_detail,)
|
|
113
|
+
error["status_code"] = (error["status"],)
|
|
114
|
+
message = (
|
|
115
|
+
error["hostname"]
|
|
116
|
+
+ " Status="
|
|
117
|
+
+ error["status"]
|
|
118
|
+
+ ": Invalid response in challenge url"
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
message = err_detail
|
|
122
|
+
|
|
123
|
+
if message is None:
|
|
124
|
+
if res_json.get('detail'):
|
|
125
|
+
message = res_json['detail']
|
|
126
|
+
else:
|
|
127
|
+
message = "Received status=" + str(self.response.status_code) + " from AMCE server"
|
|
128
|
+
if error is None:
|
|
129
|
+
error = res_json
|
|
130
|
+
|
|
131
|
+
return (message, error)
|
|
132
|
+
|
|
133
|
+
except requests.RequestException as e:
|
|
134
|
+
message = "Received status=" + str(self.response.status_code) + " from AMCE server"
|
|
135
|
+
error = {"url": self.response.request.url, "response": self.response.text}
|
|
136
|
+
return (message, error)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AcmeInvaliOrderError(AcmeHttpError):
|
|
140
|
+
def __init__(self, response: requests.Response, step: str):
|
|
141
|
+
super().__init__(response, step)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
145
|
+
res=None
|
|
146
|
+
try:
|
|
147
|
+
res = requests.request(method, url, json=json, headers=headers, timeout=15)
|
|
148
|
+
print("Request ["+str(res.status_code)+"] : " + method + " " + url + " step=" + step)
|
|
149
|
+
except requests.HTTPError as e:
|
|
150
|
+
status = res.status_code if res else None
|
|
151
|
+
status = status if status else (e.response.status_code if e.response else None)
|
|
152
|
+
if status:
|
|
153
|
+
print("Request ["+str(status)+"] : " + method + " " + url + " step=" + step)
|
|
154
|
+
else:
|
|
155
|
+
print("Request : " + method + " " + url + " step=" + step)
|
|
156
|
+
|
|
157
|
+
raise e
|
|
158
|
+
except requests.RequestException as e:
|
|
159
|
+
print("Request : " + method + " " + url + " step=" + step)
|
|
160
|
+
raise AcmeNetworkError(
|
|
161
|
+
e.request,
|
|
162
|
+
f"Error communicating with ACME server",
|
|
163
|
+
{
|
|
164
|
+
"errorType": e.__class__.__name__,
|
|
165
|
+
"message": str(e),
|
|
166
|
+
"method": method,
|
|
167
|
+
"url": e.request.url if e.request else None,
|
|
168
|
+
},
|
|
169
|
+
step,
|
|
170
|
+
)
|
|
171
|
+
if 199 <= res.status_code > 299:
|
|
172
|
+
if throw:
|
|
173
|
+
raise AcmeHttpError(res, step=step)
|
|
174
|
+
return res
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
178
|
+
return request("POST", step, url, json=json, headers=headers, throw=throw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get(step: str, url) -> requests.Response:
|
|
182
|
+
return request("GET", step, url)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Acme:
|
|
186
|
+
|
|
187
|
+
URL_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
188
|
+
URL_PROD = "https://acme-v02.api.letsencrypt.org/directory"
|
|
189
|
+
|
|
190
|
+
def __init__(self, account_key: Union[RSAPrivateKey, EllipticCurvePrivateKey], url=acme_url):
|
|
191
|
+
self.account_key = account_key
|
|
192
|
+
# json web key format for public key
|
|
193
|
+
self.jwk = jwk(self.account_key)
|
|
194
|
+
self.nonce = []
|
|
195
|
+
self.acme_url = url
|
|
196
|
+
self.key_id = None
|
|
197
|
+
self.directory = None
|
|
198
|
+
self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
|
|
199
|
+
|
|
200
|
+
def setup(self):
|
|
201
|
+
if self.directory is None:
|
|
202
|
+
self.directory = get("Fetch Acme Directory", self.acme_url).json()
|
|
203
|
+
|
|
204
|
+
def _directory(self, key):
|
|
205
|
+
if not self.directory:
|
|
206
|
+
self.directory = get("Fetch Acme Directory", self.acme_url).json()
|
|
207
|
+
return self.directory[key]
|
|
208
|
+
|
|
209
|
+
def _directory_req(self, path_name, payload, depth=0):
|
|
210
|
+
url = self._directory(path_name)
|
|
211
|
+
return self._signed_req(url, payload, depth, step="Acme request:" + path_name)
|
|
212
|
+
|
|
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""
|
|
217
|
+
nonce = None
|
|
218
|
+
with self._nonce_lock: # Acquire lock to ensure thread-safe access to nonce
|
|
219
|
+
# Check if there are any nonces available
|
|
220
|
+
if self.nonce:
|
|
221
|
+
# Pop the first nonce from the list
|
|
222
|
+
nonce = self.nonce.pop(0)
|
|
223
|
+
|
|
224
|
+
# Fetch a new nonce if the list is empty
|
|
225
|
+
nonce = (
|
|
226
|
+
nonce
|
|
227
|
+
if nonce
|
|
228
|
+
else get(
|
|
229
|
+
step + " > Fetch new Nonce" if step else "Fetch new Nonce from Acme", self._directory("newNonce")
|
|
230
|
+
).headers.get("Replay-Nonce")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
protected = {
|
|
234
|
+
"url": url,
|
|
235
|
+
"alg": get_algorithm_name(self.account_key),
|
|
236
|
+
"nonce": nonce,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if self.key_id:
|
|
240
|
+
protected["kid"] = self.key_id
|
|
241
|
+
else:
|
|
242
|
+
protected["jwk"] = self.jwk
|
|
243
|
+
protectedb64 = b64_encode(protected)
|
|
244
|
+
payload = {
|
|
245
|
+
"protected": protectedb64.decode("utf-8"),
|
|
246
|
+
"payload": payload64.decode("utf-8"),
|
|
247
|
+
"signature": b64_string(sign(self.account_key, b".".join([protectedb64, payload64]))),
|
|
248
|
+
}
|
|
249
|
+
|
|
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)
|
|
258
|
+
|
|
259
|
+
with self._nonce_lock:
|
|
260
|
+
self.nonce.append(response.headers.get("Replay-Nonce", None))
|
|
261
|
+
return response
|
|
262
|
+
|
|
263
|
+
def register(self):
|
|
264
|
+
response = self._directory_req("newAccount", {"termsOfServiceAgreed": True})
|
|
265
|
+
if "location" in response.headers:
|
|
266
|
+
self.key_id = response.headers["location"]
|
|
267
|
+
return response
|
|
268
|
+
|
|
269
|
+
def create_authorized_order(self, domains: List[str]) -> "Order":
|
|
270
|
+
payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
|
|
271
|
+
res = self._directory_req("newOrder", payload)
|
|
272
|
+
res_json = res.json()
|
|
273
|
+
challenges = []
|
|
274
|
+
for auth_url in res_json["authorizations"]:
|
|
275
|
+
auth_res = self._signed_req(auth_url, None, step="Authorize Created Order")
|
|
276
|
+
challenges.append(Challenge(auth_url, auth_res.json(), self))
|
|
277
|
+
return Order(res.headers["location"], res_json, challenges, self)
|
|
278
|
+
|
|
279
|
+
def authorize_order(self, auth_url):
|
|
280
|
+
return self._signed_req(auth_url, None, step="Authorize Created Order")
|
|
281
|
+
|
|
282
|
+
def verify_challenge(self, challenge_url):
|
|
283
|
+
return self._signed_req(challenge_url, {}, step="Verify Challenge")
|
|
284
|
+
|
|
285
|
+
def finalize_order(self):
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Order:
|
|
290
|
+
def __init__(self, url, data, challenges, acme):
|
|
291
|
+
self.url = url
|
|
292
|
+
self._data = data
|
|
293
|
+
self.all_challenges = challenges
|
|
294
|
+
self._acme = acme
|
|
295
|
+
self.status = "pending"
|
|
296
|
+
|
|
297
|
+
def remaining_challenges(self) -> List["Challenge"]:
|
|
298
|
+
return [x for x in self.all_challenges if not x.verified]
|
|
299
|
+
|
|
300
|
+
def refresh(self):
|
|
301
|
+
response = get("Fetch order Status", self.url)
|
|
302
|
+
self._data = response.json()
|
|
303
|
+
self.status = self._data["status"]
|
|
304
|
+
return response
|
|
305
|
+
|
|
306
|
+
def get_certificate(self) -> Certificate:
|
|
307
|
+
if self.status == "processing":
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"Order is still in 'processing' state! Wait until the order is finalized, and call `Order.refresh()` to update the state"
|
|
310
|
+
)
|
|
311
|
+
elif self.status != "valid":
|
|
312
|
+
raise ValueError("Order not in 'valid' state! Complete challenge and call finalize()")
|
|
313
|
+
|
|
314
|
+
certificate_res = self._acme._signed_req(
|
|
315
|
+
self._data["certificate"], step="Get Certificate from Successful Order"
|
|
316
|
+
)
|
|
317
|
+
certificate = crypto.x509.load_pem_x509_certificate(certificate_res.content)
|
|
318
|
+
return certificate
|
|
319
|
+
|
|
320
|
+
def finalize(self, csr: CertificateSigningRequest):
|
|
321
|
+
"""
|
|
322
|
+
:param csr: Private key for the
|
|
323
|
+
"""
|
|
324
|
+
finalized = self._acme._signed_req(
|
|
325
|
+
self._data["finalize"], {"csr": b64_string(csr_to_der(csr))}, step="Order Finalize"
|
|
326
|
+
)
|
|
327
|
+
finalized_json = finalized.json()
|
|
328
|
+
self._data = finalized_json
|
|
329
|
+
self.status = finalized_json["status"]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Challenge:
|
|
333
|
+
def __init__(self, auth_url, data, acme):
|
|
334
|
+
self._auth_url = auth_url
|
|
335
|
+
self._acme = acme
|
|
336
|
+
self._data = data
|
|
337
|
+
challenge = self.get_challenge()
|
|
338
|
+
self.token = challenge["token"]
|
|
339
|
+
self.verified = challenge["status"] == "valid"
|
|
340
|
+
|
|
341
|
+
jwk_json = json.dumps(self._acme.jwk, sort_keys=True, separators=(",", ":"))
|
|
342
|
+
thumbprint = b64_encode(digest_sha256(jwk_json.encode("utf8")))
|
|
343
|
+
self.authorization_key = "{0}.{1}".format(self.token, thumbprint.decode("utf-8"))
|
|
344
|
+
|
|
345
|
+
self.url = "http://{0}/.well-known/acme-challenge/{1}".format(data["identifier"]["value"], self.token)
|
|
346
|
+
|
|
347
|
+
def verify(self) -> bool:
|
|
348
|
+
if not self.verified:
|
|
349
|
+
response = self._acme._signed_req(self.get_challenge()["url"], {}, step="Verify Challenge", throw=False)
|
|
350
|
+
if response.status_code == 200 and response.json()["status"] == "valid":
|
|
351
|
+
self.verified = True
|
|
352
|
+
return True
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
def self_verify(self) -> Union[bool, requests.Response]:
|
|
357
|
+
identifier = self._data["identifier"]
|
|
358
|
+
if identifier["type"] == "dns":
|
|
359
|
+
res = get("Self Domain verification", self.url)
|
|
360
|
+
if res.status_code == 200 and res.content == self.token.encode():
|
|
361
|
+
return True
|
|
362
|
+
else:
|
|
363
|
+
return res
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
def query_progress(self) -> bool:
|
|
367
|
+
if self.verified:
|
|
368
|
+
return True
|
|
369
|
+
else:
|
|
370
|
+
res = self._acme._signed_req(self._auth_url, step="Acme Challenge Verification")
|
|
371
|
+
res_json = res.json()
|
|
372
|
+
if res_json["status"] == "valid":
|
|
373
|
+
self.verified = True
|
|
374
|
+
return True
|
|
375
|
+
elif res_json["status"] == "invalid":
|
|
376
|
+
raise AcmeInvaliOrderError(res, "Acme Challenge Verification")
|
|
377
|
+
else:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def get_challenge(self, key="http-01"):
|
|
381
|
+
for method in self._data["challenges"]:
|
|
382
|
+
if method["type"] == key:
|
|
383
|
+
return method
|
|
384
|
+
raise AcmeError(
|
|
385
|
+
"'http-01' not found in challenges", {"response": self._data["challenges"]}, "Acme Challenge Verification"
|
|
386
|
+
)
|
certapi/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from .Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
|
|
2
|
+
from .certauthority import CertAuthority
|
|
3
|
+
from .crypto import gen_key_ed25519, create_csr
|
|
4
|
+
from .db import KeyStore, FilesystemKeyStore, SqliteKeyStore, PostgresKeyStore
|
|
5
|
+
from .challenge import InMemoryChallengeStore, FileSystemChallengeStore
|
certapi/certauthority.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from typing import List, Union, Callable, Tuple, Dict
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from cryptography.x509 import Certificate
|
|
7
|
+
from requests import Response
|
|
8
|
+
|
|
9
|
+
from . import Acme, Challenge, Order
|
|
10
|
+
from . import crypto
|
|
11
|
+
from . import challenge
|
|
12
|
+
from .crypto import cert_to_pem, key_to_pem
|
|
13
|
+
from .crypto_classes import Key
|
|
14
|
+
from .db import KeyStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
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)
|
|
20
|
+
self.key_store = key_store
|
|
21
|
+
self.challengesStore: challenge.ChallengeStore = challenge_store
|
|
22
|
+
|
|
23
|
+
def setup(self):
|
|
24
|
+
self.acme.setup()
|
|
25
|
+
res: Response = self.acme.register()
|
|
26
|
+
if res.status_code == 201:
|
|
27
|
+
print("Acme Account was already registered")
|
|
28
|
+
elif res.status_code != 200:
|
|
29
|
+
raise Exception("Acme registration didn't return 200 or 201 ", res.json())
|
|
30
|
+
|
|
31
|
+
def obtainCert(
|
|
32
|
+
self, host: Union[str, List[str]]
|
|
33
|
+
) -> Union[Tuple["CertificateResponse", None], Tuple[None, requests.Response]]:
|
|
34
|
+
if type(host) == str:
|
|
35
|
+
host = [host]
|
|
36
|
+
|
|
37
|
+
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
|
+
missing = [h for h in host if h not in existing]
|
|
39
|
+
if len(missing) > 0:
|
|
40
|
+
private_key = crypto.gen_key_secp256r1()
|
|
41
|
+
order = self.acme.create_authorized_order(missing)
|
|
42
|
+
|
|
43
|
+
challenges = order.remaining_challenges()
|
|
44
|
+
for c in challenges:
|
|
45
|
+
print("[ Challenge ]", c.token, "=", c.authorization_key)
|
|
46
|
+
self.challengesStore[c.token] = c.authorization_key
|
|
47
|
+
for c in challenges:
|
|
48
|
+
# c.self_verify()
|
|
49
|
+
c.verify()
|
|
50
|
+
|
|
51
|
+
end = time.time() + 40 # max 12 seconds
|
|
52
|
+
source: List[Challenge] = [x for x in challenges]
|
|
53
|
+
sink = []
|
|
54
|
+
counter = 1
|
|
55
|
+
while len(source) > 0:
|
|
56
|
+
if time.time() > end and counter > 4:
|
|
57
|
+
print("Order finalization time out")
|
|
58
|
+
break
|
|
59
|
+
for c in source:
|
|
60
|
+
status = c.query_progress()
|
|
61
|
+
if status != True: # NOTE that it must be True strictly
|
|
62
|
+
sink.append(c)
|
|
63
|
+
if len(sink) > 0:
|
|
64
|
+
time.sleep(3)
|
|
65
|
+
source, sink, counter = sink, [], counter + 1
|
|
66
|
+
else:
|
|
67
|
+
print("Order is already Ready.")
|
|
68
|
+
csr = crypto.create_csr(private_key, missing[0], missing[1:])
|
|
69
|
+
order.finalize(csr)
|
|
70
|
+
|
|
71
|
+
def obtain_cert(count=5):
|
|
72
|
+
time.sleep(3)
|
|
73
|
+
order.refresh() # is this refresh necessary?
|
|
74
|
+
|
|
75
|
+
if order.status == "valid":
|
|
76
|
+
(certificate, _) = order.get_certificate()
|
|
77
|
+
key_id = self.key_store.save_key(private_key, missing[0])
|
|
78
|
+
cert_id = self.key_store.save_cert(key_id, certificate, missing)
|
|
79
|
+
issued_cert = IssuedCert(key_to_pem(private_key), certificate, missing)
|
|
80
|
+
response = createExistingResponse(existing, [issued_cert])
|
|
81
|
+
return (response, None)
|
|
82
|
+
elif order.status == "processing":
|
|
83
|
+
if count == 0:
|
|
84
|
+
return None
|
|
85
|
+
return obtain_cert()
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
return obtain_cert()
|
|
89
|
+
else:
|
|
90
|
+
return createExistingResponse(existing, []), None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate]], issued_certs: List["IssuedCert"]):
|
|
94
|
+
certs = []
|
|
95
|
+
certMap = {}
|
|
96
|
+
for h, (id, key, cert) in existing.items():
|
|
97
|
+
if id in certMap:
|
|
98
|
+
certMap[id][0].append(h)
|
|
99
|
+
else:
|
|
100
|
+
certMap[id] = (
|
|
101
|
+
[h],
|
|
102
|
+
key.to_pem().decode("utf-8"),
|
|
103
|
+
cert_to_pem(cert).decode("utf-8"),
|
|
104
|
+
)
|
|
105
|
+
for hosts, key, cert in certMap.values():
|
|
106
|
+
certs.append(IssuedCert(key, cert, hosts))
|
|
107
|
+
|
|
108
|
+
return CertificateResponse(certs, issued_certs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CertificateResponse:
|
|
112
|
+
def __init__(self, existing, issued):
|
|
113
|
+
self.existing: List[IssuedCert] = existing
|
|
114
|
+
self.issued: List[IssuedCert] = issued
|
|
115
|
+
|
|
116
|
+
def __repr__(self):
|
|
117
|
+
return "CertificateResponse(existing={0},new={1})".format(repr(self.existing), repr(self.issued))
|
|
118
|
+
|
|
119
|
+
def __str__(self):
|
|
120
|
+
if self.issued:
|
|
121
|
+
return "(existing: {0},new: {1})".format(str(self.existing), str(self.issued))
|
|
122
|
+
else:
|
|
123
|
+
return "(existing: {0})".format(str(self.existing))
|
|
124
|
+
|
|
125
|
+
def __json__(self):
|
|
126
|
+
return {
|
|
127
|
+
"existing": [x.__json__() for x in self.existing],
|
|
128
|
+
"issued": [x.__json__() for x in self.issued],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class IssuedCert:
|
|
133
|
+
def __init__(self, key: str | Key, cert: str | Certificate, domains: [str]):
|
|
134
|
+
if isinstance(key, Key):
|
|
135
|
+
key = key.to_pem().decode("utf-8")
|
|
136
|
+
elif isinstance(key, bytes):
|
|
137
|
+
key = key.decode("utf-8")
|
|
138
|
+
if isinstance(cert, Certificate):
|
|
139
|
+
cert = cert_to_pem(cert).decode("utf-8")
|
|
140
|
+
elif isinstance(cert, bytes):
|
|
141
|
+
cert = cert.decode("utf-8")
|
|
142
|
+
self.privateKey = key
|
|
143
|
+
self.certificate = cert
|
|
144
|
+
self.domains = domains
|
|
145
|
+
|
|
146
|
+
def __repr__(self):
|
|
147
|
+
# return "IssuedCert(hosts={0})".format(self.domains)
|
|
148
|
+
return "(hosts: {0}, certificate:{1})".format(self.domains, self.certificate)
|
|
149
|
+
|
|
150
|
+
def __str__(self):
|
|
151
|
+
return "(hosts: {0}, certificate:{1})".format(self.domains, self.certificate)
|
|
152
|
+
|
|
153
|
+
def __json__(self):
|
|
154
|
+
return {"privateKey": self.privateKey, "certificate": self.certificate, "domains": self.domains}
|
certapi/challenge.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import MutableMapping
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ChallengeStore(MutableMapping):
|
|
6
|
+
"""
|
|
7
|
+
Abstract base class for a challenge store.
|
|
8
|
+
Provides dictionary-like behavior by inheriting from MutableMapping.
|
|
9
|
+
"""
|
|
10
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
def __delitem__(self, key):
|
|
21
|
+
if key not in self:
|
|
22
|
+
raise KeyError(key)
|
|
23
|
+
self.delete_challenge(key)
|
|
24
|
+
|
|
25
|
+
def __contains__(self, key):
|
|
26
|
+
return self.get_challenge(key) is not None
|
|
27
|
+
|
|
28
|
+
def __iter__(self):
|
|
29
|
+
raise NotImplementedError("Must implement `__iter__` method.")
|
|
30
|
+
|
|
31
|
+
def __len__(self):
|
|
32
|
+
raise NotImplementedError("Must implement `__len__` method.")
|
|
33
|
+
|
|
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
|
+
|
|
44
|
+
class InMemoryChallengeStore(ChallengeStore):
|
|
45
|
+
"""
|
|
46
|
+
In-memory implementation of the ChallengeStore.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.challenges = {}
|
|
51
|
+
|
|
52
|
+
def save_challenge(self, key: str, value: str):
|
|
53
|
+
self.challenges[key] = value
|
|
54
|
+
|
|
55
|
+
def get_challenge(self, key: str) -> str:
|
|
56
|
+
return self.challenges.get(key, "")
|
|
57
|
+
|
|
58
|
+
def delete_challenge(self, key: str):
|
|
59
|
+
if key in self.challenges:
|
|
60
|
+
del self.challenges[key]
|
|
61
|
+
|
|
62
|
+
def __iter__(self):
|
|
63
|
+
return iter(self.challenges)
|
|
64
|
+
|
|
65
|
+
def __len__(self):
|
|
66
|
+
return len(self.challenges)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FileSystemChallengeStore(ChallengeStore):
|
|
70
|
+
"""
|
|
71
|
+
Filesystem implementation of the ChallengeStore.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, directory: str):
|
|
75
|
+
self.directory = directory
|
|
76
|
+
os.makedirs(self.directory, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
def save_challenge(self, key: str, value: str):
|
|
79
|
+
file_path = os.path.join(self.directory, key)
|
|
80
|
+
with open(file_path, "w") as file:
|
|
81
|
+
file.write(value)
|
|
82
|
+
|
|
83
|
+
def get_challenge(self, key: str) -> str:
|
|
84
|
+
file_path = os.path.join(self.directory, key)
|
|
85
|
+
if not os.path.exists(file_path):
|
|
86
|
+
return None
|
|
87
|
+
with open(file_path, "r") as file:
|
|
88
|
+
return file.read()
|
|
89
|
+
|
|
90
|
+
def delete_challenge(self, key: str):
|
|
91
|
+
file_path = os.path.join(self.directory, key)
|
|
92
|
+
if os.path.exists(file_path):
|
|
93
|
+
os.remove(file_path)
|
|
94
|
+
|
|
95
|
+
def __iter__(self):
|
|
96
|
+
return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
|
|
97
|
+
|
|
98
|
+
def __len__(self):
|
|
99
|
+
return len([f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f))])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_challenge_store():
|
|
103
|
+
"""
|
|
104
|
+
Factory function to determine the type of store based on environment variables.
|
|
105
|
+
|
|
106
|
+
Environment Variables:
|
|
107
|
+
- `CHALLENGE_STORE_TYPE`: Can be "memory" or "filesystem".
|
|
108
|
+
- `CHALLENGE_STORE_DIR`: Directory for filesystem-based store. Defaults to "./challenges".
|
|
109
|
+
"""
|
|
110
|
+
store_type = os.getenv("CHALLENGE_STORE_TYPE", "filesystem").lower()
|
|
111
|
+
directory = os.getenv("CHALLENGE_STORE_DIR", "./challenges")
|
|
112
|
+
|
|
113
|
+
if store_type == "memory":
|
|
114
|
+
return InMemoryChallengeStore()
|
|
115
|
+
elif store_type == "filesystem":
|
|
116
|
+
return FileSystemChallengeStore(directory)
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(f"Unknown CHALLENGE_STORE_TYPE: {store_type}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
challenge_store: ChallengeStore = get_challenge_store()
|