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 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
@@ -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()