certapi 0.3.0__tar.gz → 0.4.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.3.0 → certapi-0.4.0}/PKG-INFO +1 -1
- {certapi-0.3.0 → certapi-0.4.0}/pyproject.toml +1 -1
- {certapi-0.3.0 → certapi-0.4.0}/setup.py +1 -1
- certapi-0.4.0/src/certapi/__init__.py +9 -0
- certapi-0.4.0/src/certapi/acme/Acme.py +123 -0
- certapi-0.4.0/src/certapi/acme/AcmeError.py +141 -0
- certapi-0.4.0/src/certapi/acme/Challenge.py +90 -0
- certapi-0.4.0/src/certapi/acme/Order.py +49 -0
- certapi-0.4.0/src/certapi/acme/__init__.py +4 -0
- certapi-0.4.0/src/certapi/acme/http.py +56 -0
- certapi-0.4.0/src/certapi/challenge_store/ChallengeStore.py +34 -0
- certapi-0.4.0/src/certapi/challenge_store/FileSystemChallengeStore.py +38 -0
- certapi-0.4.0/src/certapi/challenge_store/FilesystemChallengeStore.py +38 -0
- certapi-0.4.0/src/certapi/challenge_store/InmemoryChallengeStore.py +26 -0
- certapi-0.4.0/src/certapi/challenge_store/__init__.py +25 -0
- certapi-0.4.0/src/certapi/challenge_store/dns/__init__.py +2 -0
- certapi-0.4.0/src/certapi/client/__init__.py +0 -0
- certapi-0.4.0/src/certapi/client/cert_manager_client.py +98 -0
- certapi-0.4.0/src/certapi/crypto/__init__.py +2 -0
- {certapi-0.3.0/src/certapi → certapi-0.4.0/src/certapi/crypto}/crypto.py +31 -78
- {certapi-0.3.0/src/certapi → certapi-0.4.0/src/certapi/crypto}/crypto_classes.py +26 -14
- certapi-0.4.0/src/certapi/issuers/AcmeCertIssuer.py +145 -0
- certapi-0.3.0/src/certapi/custom_certauthority.py → certapi-0.4.0/src/certapi/issuers/SelfCertIssuer.py +10 -50
- certapi-0.4.0/src/certapi/issuers/__init__.py +3 -0
- certapi-0.4.0/src/certapi/issuers/abstract_certissuer.py +95 -0
- certapi-0.4.0/src/certapi/keystore/FileSystemKeystore.py +99 -0
- certapi-0.4.0/src/certapi/keystore/KeyStore.py +66 -0
- certapi-0.4.0/src/certapi/keystore/PostgresqlKeyStore.py +229 -0
- certapi-0.4.0/src/certapi/keystore/RemoteKeyStore.py +52 -0
- certapi-0.4.0/src/certapi/keystore/SqliteKeyStore.py +200 -0
- certapi-0.4.0/src/certapi/keystore/__init__.py +4 -0
- certapi-0.4.0/src/certapi/server/__init__.py +0 -0
- certapi-0.4.0/src/certapi/server/api.py +89 -0
- certapi-0.4.0/src/certapi/server/cert_api.py +110 -0
- certapi-0.4.0/src/certapi/server/key_api.py +79 -0
- certapi-0.4.0/src/certapi/utils.py +34 -0
- {certapi-0.3.0 → certapi-0.4.0}/src/certapi.egg-info/PKG-INFO +1 -1
- certapi-0.4.0/src/certapi.egg-info/SOURCES.txt +43 -0
- certapi-0.3.0/src/certapi/Acme.py +0 -426
- certapi-0.3.0/src/certapi/__init__.py +0 -6
- certapi-0.3.0/src/certapi/certauthority.py +0 -224
- certapi-0.3.0/src/certapi/challenge.py +0 -103
- certapi-0.3.0/src/certapi/cloudflare_challenge_store.py +0 -57
- certapi-0.3.0/src/certapi/cloudflare_client.py +0 -137
- certapi-0.3.0/src/certapi/db.py +0 -408
- certapi-0.3.0/src/certapi/digitalocean_challenge_store.py +0 -57
- certapi-0.3.0/src/certapi/digitalocean_client.py +0 -85
- certapi-0.3.0/src/certapi.egg-info/SOURCES.txt +0 -22
- {certapi-0.3.0 → certapi-0.4.0}/MANIFEST.in +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/README.md +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/setup.cfg +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/src/certapi/util.py +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.3.0 → certapi-0.4.0}/src/certapi.egg-info/top_level.txt +0 -0
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
line-length = 120
|
|
3
3
|
|
|
4
4
|
[tool.pytest.ini_options]
|
|
5
|
-
|
|
6
5
|
minversion = "6.0"
|
|
7
6
|
addopts = "-ra -q"
|
|
8
7
|
testpaths = [
|
|
@@ -10,6 +9,7 @@ testpaths = [
|
|
|
10
9
|
]
|
|
11
10
|
env_files =[".env", ".test.env"]
|
|
12
11
|
pythonpath = "src"
|
|
12
|
+
timeout = 10
|
|
13
13
|
|
|
14
14
|
[build-system]
|
|
15
15
|
requires = ["setuptools>=42"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .acme.Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
|
|
2
|
+
from .manager.acme_cert_manager import AcmeCertManager
|
|
3
|
+
from .http.types import CertificateResponse, IssuedCert
|
|
4
|
+
|
|
5
|
+
from .crypto import Certificate,CertificateSigningRequest,CertificateSigningRequestBuilder \
|
|
6
|
+
,Key,Ed25519Key,ECDSAKey,Ed25519PrivateKey,EllipticCurvePrivateKey
|
|
7
|
+
from .keystore import FileSystemKeystore,SqliteKeyStore,PostgresKeyStore,KeyStore
|
|
8
|
+
from .challenge_store import ChallengeStore, InMemoryChallengeStore, FileSystemChallengeStore,CloudflareChallengeStore,DigitalOceanChallengeStore
|
|
9
|
+
from .issuers import SelfCertIssuer
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Union, List, Tuple
|
|
6
|
+
import json
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
9
|
+
from cryptography.x509 import CertificateSigningRequest, Certificate
|
|
10
|
+
from certapi.crypto import crypto
|
|
11
|
+
import requests
|
|
12
|
+
from certapi.crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name, sign_for_jws,Key
|
|
13
|
+
from certapi.util import b64_encode, b64_string
|
|
14
|
+
from .AcmeError import *
|
|
15
|
+
from .http import *
|
|
16
|
+
from .Challenge import Challenge
|
|
17
|
+
from .Order import Order
|
|
18
|
+
|
|
19
|
+
class Acme:
|
|
20
|
+
|
|
21
|
+
URL_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
22
|
+
URL_PROD = "https://acme-v02.api.letsencrypt.org/directory"
|
|
23
|
+
|
|
24
|
+
def __init__(self, account_key: Union[RSAPrivateKey, EllipticCurvePrivateKey | Key], url=URL_STAGING):
|
|
25
|
+
self.account_key = account_key.key if isinstance(account_key, Key) else account_key
|
|
26
|
+
|
|
27
|
+
# json web key format for public key
|
|
28
|
+
self.jwk = jwk(self.account_key)
|
|
29
|
+
print(self.jwk)
|
|
30
|
+
self.nonce = []
|
|
31
|
+
self.acme_url = url if url else os.environ.get("ACME_API_URL", self.URL_STAGING)
|
|
32
|
+
self.key_id = None
|
|
33
|
+
self.directory = None
|
|
34
|
+
self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
|
|
35
|
+
|
|
36
|
+
def setup(self):
|
|
37
|
+
if self.directory is None:
|
|
38
|
+
self.directory = get("Fetch Acme Directory", self.acme_url).json()
|
|
39
|
+
|
|
40
|
+
def _directory(self, key):
|
|
41
|
+
if not self.directory:
|
|
42
|
+
self.directory = get("Fetch Acme Directory", self.acme_url).json()
|
|
43
|
+
return self.directory[key]
|
|
44
|
+
|
|
45
|
+
def _directory_req(self, path_name, payload, depth=0):
|
|
46
|
+
url = self._directory(path_name)
|
|
47
|
+
return self._signed_req(url, payload, depth, step="Acme request:" + path_name)
|
|
48
|
+
|
|
49
|
+
def get_nonce(self, step: str, counter=1):
|
|
50
|
+
nonce = None
|
|
51
|
+
with self._nonce_lock: # Acquire nonce
|
|
52
|
+
if self.nonce:
|
|
53
|
+
nonce = self.nonce.pop(0)
|
|
54
|
+
return (
|
|
55
|
+
nonce
|
|
56
|
+
if nonce
|
|
57
|
+
else get(
|
|
58
|
+
step + " > Fetch new Nonce" if step else "Fetch new Nonce from Acme", self._directory("newNonce")
|
|
59
|
+
).headers.get("Replay-Nonce")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def record_nonce(self, response: requests.Response) -> requests.Response:
|
|
63
|
+
with self._nonce_lock:
|
|
64
|
+
self.nonce.append(response.headers.get("Replay-Nonce", None))
|
|
65
|
+
return response
|
|
66
|
+
|
|
67
|
+
def _signed_req(
|
|
68
|
+
self, url, payload: Union[str, dict, list, bytes, None] = None, depth=0, step="Acme Request", throw=True
|
|
69
|
+
) -> requests.Response:
|
|
70
|
+
payload64 = b64_encode(payload) if payload is not None else b""
|
|
71
|
+
|
|
72
|
+
protected = {
|
|
73
|
+
"url": url,
|
|
74
|
+
"alg": get_algorithm_name(self.account_key),
|
|
75
|
+
"nonce": self.get_nonce(step),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if self.key_id:
|
|
79
|
+
protected["kid"] = self.key_id
|
|
80
|
+
else:
|
|
81
|
+
protected["jwk"] = self.jwk
|
|
82
|
+
protectedb64 = b64_encode(protected)
|
|
83
|
+
payload = {
|
|
84
|
+
"protected": protectedb64.decode("utf-8"),
|
|
85
|
+
"payload": payload64.decode("utf-8"),
|
|
86
|
+
"signature": b64_string(sign_for_jws(self.account_key, b".".join([protectedb64, payload64]))),
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
|
|
90
|
+
response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
|
|
91
|
+
except AcmeError as e:
|
|
92
|
+
if e.can_retry and depth <= 0:
|
|
93
|
+
time.sleep(2)
|
|
94
|
+
return self._signed_req(url, payload, depth + 1, step, throw)
|
|
95
|
+
else:
|
|
96
|
+
raise e
|
|
97
|
+
|
|
98
|
+
return self.record_nonce(response)
|
|
99
|
+
|
|
100
|
+
def register(self):
|
|
101
|
+
response = self._directory_req("newAccount", {"termsOfServiceAgreed": True})
|
|
102
|
+
if "location" in response.headers:
|
|
103
|
+
self.key_id = response.headers["location"]
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
def create_authorized_order(self, domains: List[str]) -> "Order":
|
|
107
|
+
payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
|
|
108
|
+
res = self._directory_req("newOrder", payload)
|
|
109
|
+
res_json = res.json()
|
|
110
|
+
challenges = []
|
|
111
|
+
for auth_url in res_json["authorizations"]:
|
|
112
|
+
auth_res = self._signed_req(auth_url, None, step="Authorize Created Order")
|
|
113
|
+
challenges.append(Challenge(auth_url, auth_res.json(), self))
|
|
114
|
+
return Order(res.headers["location"], res_json, challenges, self)
|
|
115
|
+
|
|
116
|
+
def authorize_order(self, auth_url):
|
|
117
|
+
return self._signed_req(auth_url, None, step="Authorize Created Order")
|
|
118
|
+
|
|
119
|
+
def verify_challenge(self, challenge_url):
|
|
120
|
+
return self._signed_req(challenge_url, {}, step="Verify Challenge")
|
|
121
|
+
|
|
122
|
+
def finalize_order(self):
|
|
123
|
+
pass
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AcmeError(Exception):
|
|
5
|
+
def __init__(self, message, detail, step):
|
|
6
|
+
super().__init__(step, message)
|
|
7
|
+
self.message: str = message
|
|
8
|
+
self.step: str = step
|
|
9
|
+
self.detail: dict = detail
|
|
10
|
+
self.can_retry = False
|
|
11
|
+
|
|
12
|
+
def json_obj(self) -> dict:
|
|
13
|
+
return {"message": self.message, "step": self.step, "detail": self.detail}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AcmeNetworkError(AcmeError, requests.RequestException):
|
|
17
|
+
"""
|
|
18
|
+
There was an error connecting/communicating with the AcmeServer.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, request: requests.Request, message, detail, step):
|
|
22
|
+
# Initialize both parent classes
|
|
23
|
+
requests.RequestException.__init__(self, request=request)
|
|
24
|
+
AcmeError.__init__(self, message, detail, step)
|
|
25
|
+
requests.RequestException.__init__(self, request=request) # Pass message to RequestException
|
|
26
|
+
self.can_retry = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
30
|
+
"""
|
|
31
|
+
Acme Server replied with error status.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, response: requests.Response, step: str):
|
|
35
|
+
requests.HTTPError.__init__(self, response=response)
|
|
36
|
+
self.response = response
|
|
37
|
+
(message, detail) = self.extract_acme_response_error()
|
|
38
|
+
AcmeError.__init__(self, message, detail, step)
|
|
39
|
+
|
|
40
|
+
def extract_acme_response_error(self):
|
|
41
|
+
message = None
|
|
42
|
+
error = None
|
|
43
|
+
try:
|
|
44
|
+
res_json = self.response.json()
|
|
45
|
+
if res_json["status"] == "invalid":
|
|
46
|
+
if res_json["challenges"]:
|
|
47
|
+
failed_challenge: dict = [x for x in res_json["challenges"] if x["status"] == "invalid"][0]
|
|
48
|
+
error = failed_challenge["error"]
|
|
49
|
+
err_detail: str = error.get("detail")
|
|
50
|
+
validation_record: dict = failed_challenge.get("validationRecord")
|
|
51
|
+
error = {
|
|
52
|
+
"url": failed_challenge["url"],
|
|
53
|
+
}
|
|
54
|
+
if validation_record is None:
|
|
55
|
+
# Search for the pattern and extract the content
|
|
56
|
+
if err_detail.startswith("DNS problem: NXDOMAIN"):
|
|
57
|
+
error["dns"] = {"error": "DNS record doesn't exist"}
|
|
58
|
+
error["hostname"] = re.findall(r"looking up [A-Z]+ for ([\w.-]+)", err_detail)[0]
|
|
59
|
+
message = error["hostname"] + " doesn't have a valid DNS record"
|
|
60
|
+
elif err_detail:
|
|
61
|
+
error["dns"] = {"error": err_detail}
|
|
62
|
+
message = err_detail
|
|
63
|
+
else:
|
|
64
|
+
error["dns"] = {"error": error}
|
|
65
|
+
message = err_detail
|
|
66
|
+
else:
|
|
67
|
+
validation_record = validation_record[0]
|
|
68
|
+
error["hostname"] = validation_record["hostname"]
|
|
69
|
+
error["dns"] = {
|
|
70
|
+
"resolved": validation_record["addressesResolved"],
|
|
71
|
+
"used": validation_record["addressUsed"],
|
|
72
|
+
}
|
|
73
|
+
if error["type"] == "urn:ietf:params:acme:error:connection":
|
|
74
|
+
if "Timeout during connect" in err_detail:
|
|
75
|
+
error["connect"] = {"error": "Timeout"}
|
|
76
|
+
message = (
|
|
77
|
+
error["hostname"]
|
|
78
|
+
+ "["
|
|
79
|
+
+ validation_record["addressUsed"]
|
|
80
|
+
+ ":"
|
|
81
|
+
+ validation_record["port"]
|
|
82
|
+
+ "] Connect Timeout (Maybe firewall reasons)"
|
|
83
|
+
)
|
|
84
|
+
elif err_detail.endswith("Connection refused"):
|
|
85
|
+
error["connect"] = {"error": "connection refused"}
|
|
86
|
+
message = (
|
|
87
|
+
error["hostname"]
|
|
88
|
+
+ "["
|
|
89
|
+
+ validation_record["addressUsed"]
|
|
90
|
+
+ ":"
|
|
91
|
+
+ validation_record["port"]
|
|
92
|
+
+ "] Connection Refused (Is http server running?)"
|
|
93
|
+
)
|
|
94
|
+
elif err_detail:
|
|
95
|
+
message = err_detail
|
|
96
|
+
else:
|
|
97
|
+
message = error
|
|
98
|
+
elif err_detail:
|
|
99
|
+
pattern = r'Invalid response from .*?: "(.*)"'
|
|
100
|
+
|
|
101
|
+
match = re.search(pattern, err_detail)
|
|
102
|
+
|
|
103
|
+
if match:
|
|
104
|
+
error["response"] = (match.group(1) if match is not None else err_detail,)
|
|
105
|
+
error["status_code"] = (error["status"],)
|
|
106
|
+
message = (
|
|
107
|
+
error["hostname"]
|
|
108
|
+
+ " Status="
|
|
109
|
+
+ error["status"]
|
|
110
|
+
+ ": Invalid response in challenge url"
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
message = err_detail
|
|
114
|
+
else:
|
|
115
|
+
message = error
|
|
116
|
+
|
|
117
|
+
if message is None:
|
|
118
|
+
if res_json.get("detail"):
|
|
119
|
+
message = res_json["detail"]
|
|
120
|
+
else:
|
|
121
|
+
message = "Received status=" + str(self.response.status_code) + " from AMCE server"
|
|
122
|
+
if error is None:
|
|
123
|
+
error = res_json
|
|
124
|
+
|
|
125
|
+
return (message, error)
|
|
126
|
+
|
|
127
|
+
except requests.RequestException as e:
|
|
128
|
+
message = "Received status=" + str(self.response.status_code) + " from AMCE server"
|
|
129
|
+
error = {"url": self.response.request.url, "response": self.response.text}
|
|
130
|
+
return (message, error)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AcmeInvaliOrderError(AcmeHttpError):
|
|
134
|
+
def __init__(self, response: requests.Response, step: str):
|
|
135
|
+
super().__init__(response, step)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AcmeInvaliNonceError(AcmeHttpError):
|
|
139
|
+
def __init__(self, response: requests.Response, step: str):
|
|
140
|
+
super().__init__(response, step)
|
|
141
|
+
self.can_retry = True
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from typing import Literal, Union
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from certapi.util import b64_encode, b64_string
|
|
8
|
+
from certapi.crypto import digest_sha256
|
|
9
|
+
from .AcmeError import *
|
|
10
|
+
from .http import post,get
|
|
11
|
+
|
|
12
|
+
class Challenge:
|
|
13
|
+
def __init__(self, auth_url, data, acme):
|
|
14
|
+
self._auth_url = auth_url
|
|
15
|
+
self._acme = acme
|
|
16
|
+
self._data = data
|
|
17
|
+
challenge = self.get_challenge()
|
|
18
|
+
self.token:str = challenge["token"]
|
|
19
|
+
self.verified = challenge["status"] == "valid"
|
|
20
|
+
self.domain = data["identifier"]["value"] # Add domain attribute
|
|
21
|
+
|
|
22
|
+
jwk_json = json.dumps(self._acme.jwk, sort_keys=True, separators=(",", ":"))
|
|
23
|
+
thumbprint = b64_encode(digest_sha256(jwk_json.encode("utf8")))
|
|
24
|
+
self.authorization_key = "{0}.{1}".format(self.token, thumbprint.decode("utf-8"))
|
|
25
|
+
|
|
26
|
+
self.url = "http://{0}/.well-known/acme-challenge/{1}".format(data["identifier"]["value"], self.token)
|
|
27
|
+
|
|
28
|
+
def as_key_value(self,type:Literal["http-01", "dns-01", "tls-alpn-01"]=None):
|
|
29
|
+
challenge=self.get_challenge(type)
|
|
30
|
+
|
|
31
|
+
if challenge["type"] == "dns-01":
|
|
32
|
+
key = f"_acme-challenge.{self.domain}"
|
|
33
|
+
value = b64_string(digest_sha256(self.authorization_key.encode("utf8")))
|
|
34
|
+
else:
|
|
35
|
+
key = self.token
|
|
36
|
+
value = self.authorization_key
|
|
37
|
+
|
|
38
|
+
return (key,value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify(self, type:Literal["http-01", "dns-01", "tls-alpn-01"]=None) -> bool:
|
|
42
|
+
if not self.verified:
|
|
43
|
+
response = self._acme._signed_req(
|
|
44
|
+
self.get_challenge(type)["url"], {}, step="Verify Challenge", throw=False
|
|
45
|
+
)
|
|
46
|
+
if response.status_code == 200 and response.json()["status"] == "valid":
|
|
47
|
+
self.verified = True
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
def self_verify(self) -> Union[bool, requests.Response]:
|
|
53
|
+
identifier = self._data["identifier"]
|
|
54
|
+
if identifier["type"] == "dns":
|
|
55
|
+
res = get("Self Domain verification", self.url)
|
|
56
|
+
if res.status_code == 200 and res.content == self.token.encode():
|
|
57
|
+
return True
|
|
58
|
+
else:
|
|
59
|
+
return res
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def query_progress(self) -> bool:
|
|
63
|
+
if self.verified:
|
|
64
|
+
return True
|
|
65
|
+
else:
|
|
66
|
+
res = self._acme._signed_req(self._auth_url, step="Acme Challenge Verification")
|
|
67
|
+
res_json = res.json()
|
|
68
|
+
if res_json["status"] == "valid":
|
|
69
|
+
self.verified = True
|
|
70
|
+
return True
|
|
71
|
+
elif res_json["status"] == "invalid":
|
|
72
|
+
raise AcmeInvaliOrderError(res, "Acme Challenge Verification")
|
|
73
|
+
else:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def get_challenge(self, type:Literal["http-01", "dns-01", "tls-alpn-01"]=None):
|
|
77
|
+
challenges = self._data["challenges"]
|
|
78
|
+
def_key= type if type is not None else "http-01"
|
|
79
|
+
for method in challenges:
|
|
80
|
+
if method["type"] == def_key:
|
|
81
|
+
return method
|
|
82
|
+
if type is None:
|
|
83
|
+
return challenges[0]
|
|
84
|
+
|
|
85
|
+
ch_types = [x["type"] for x in self._data["challenges"]]
|
|
86
|
+
raise AcmeError(
|
|
87
|
+
f"'{type}' not found in challenges. available:{str(ch_types)}",
|
|
88
|
+
{"response": self._data["challenges"]},
|
|
89
|
+
"Acme Challenge Verification",
|
|
90
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import List, Tuple
|
|
2
|
+
from certapi.crypto import crypto,csr_to_der
|
|
3
|
+
from certapi.util import b64_string
|
|
4
|
+
from cryptography.x509 import CertificateSigningRequest,Certificate
|
|
5
|
+
from .Challenge import *
|
|
6
|
+
from .http import get
|
|
7
|
+
|
|
8
|
+
class Order:
|
|
9
|
+
def __init__(self, url, data, challenges, acme):
|
|
10
|
+
self.url = url
|
|
11
|
+
self._data = data
|
|
12
|
+
self.all_challenges = challenges
|
|
13
|
+
self._acme = acme
|
|
14
|
+
self.status = "pending"
|
|
15
|
+
|
|
16
|
+
def remaining_challenges(self) -> List["Challenge"]:
|
|
17
|
+
return [x for x in self.all_challenges if not x.verified]
|
|
18
|
+
|
|
19
|
+
def refresh(self):
|
|
20
|
+
response = get("Fetch order Status", self.url)
|
|
21
|
+
self._data = response.json()
|
|
22
|
+
self.status = self._data["status"]
|
|
23
|
+
return response
|
|
24
|
+
|
|
25
|
+
def get_certificate(self) -> str:
|
|
26
|
+
if self.status == "processing":
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"Order is still in 'processing' state! Wait until the order is finalized, and call `Order.refresh()` to update the state"
|
|
29
|
+
)
|
|
30
|
+
elif self.status != "valid":
|
|
31
|
+
raise ValueError("Order not in 'valid' state! Complete challenge and call finalize()")
|
|
32
|
+
|
|
33
|
+
certificate_res = self._acme._signed_req(
|
|
34
|
+
self._data["certificate"], step="Get Certificate from Successful Order"
|
|
35
|
+
)
|
|
36
|
+
return certificate_res.text
|
|
37
|
+
|
|
38
|
+
def finalize(self, csr: CertificateSigningRequest):
|
|
39
|
+
"""
|
|
40
|
+
Finalizes the order with the provided CSR.
|
|
41
|
+
|
|
42
|
+
:param csr: The Certificate Signing Request (CSR) to be used for finalizing the order.
|
|
43
|
+
"""
|
|
44
|
+
finalized = self._acme._signed_req(
|
|
45
|
+
self._data["finalize"], {"csr": b64_string(csr_to_der(csr))}, step="Order Finalize"
|
|
46
|
+
)
|
|
47
|
+
finalized_json = finalized.json()
|
|
48
|
+
self._data = finalized_json
|
|
49
|
+
self.status = finalized_json["status"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
|
|
2
|
+
import requests
|
|
3
|
+
from .AcmeError import *
|
|
4
|
+
|
|
5
|
+
def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
6
|
+
res = None
|
|
7
|
+
try:
|
|
8
|
+
res = requests.request(method, url, json=json, headers=headers, timeout=15)
|
|
9
|
+
print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + step)
|
|
10
|
+
except requests.HTTPError as e:
|
|
11
|
+
status = res.status_code if res else None
|
|
12
|
+
status = status if status else (e.response.status_code if e.response else None)
|
|
13
|
+
if status:
|
|
14
|
+
print("Request [" + str(status) + "] : " + method + " " + url + " step=" + step)
|
|
15
|
+
else:
|
|
16
|
+
print("Request : " + method + " " + url + " step=" + step)
|
|
17
|
+
|
|
18
|
+
raise e
|
|
19
|
+
except requests.RequestException as e:
|
|
20
|
+
print("Request : " + str(method) + " " + str(url) + " step=" + str(step))
|
|
21
|
+
raise AcmeNetworkError(
|
|
22
|
+
e.request,
|
|
23
|
+
f"Error communicating with ACME server",
|
|
24
|
+
{
|
|
25
|
+
"errorType": e.__class__.__name__,
|
|
26
|
+
"message": str(e),
|
|
27
|
+
"method": method,
|
|
28
|
+
"url": e.request.url if e.request else None,
|
|
29
|
+
},
|
|
30
|
+
step,
|
|
31
|
+
)
|
|
32
|
+
if 199 <= res.status_code > 299:
|
|
33
|
+
[print(x, y) for (x, y) in res.headers.items()]
|
|
34
|
+
print("Response:", res.text)
|
|
35
|
+
json_data = None
|
|
36
|
+
try:
|
|
37
|
+
json_data = res.json()
|
|
38
|
+
except requests.RequestException as e:
|
|
39
|
+
pass
|
|
40
|
+
if json_data and json_data.get("type"):
|
|
41
|
+
errorType = json_data["type"]
|
|
42
|
+
if errorType == "urn:ietf:params:acme:error:badNonce":
|
|
43
|
+
raise AcmeInvaliNonceError(res, step=step)
|
|
44
|
+
|
|
45
|
+
if throw:
|
|
46
|
+
raise AcmeHttpError(res, step=step)
|
|
47
|
+
return res
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
51
|
+
return request("POST", step, url, json=json, headers=headers, throw=throw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get(step: str, url) -> requests.Response:
|
|
55
|
+
return request("GET", step, url)
|
|
56
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import MutableMapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChallengeStore(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for a challenge store.
|
|
9
|
+
"""
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def supports_domain(self, domain: str) -> bool:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
24
|
+
pass
|
|
25
|
+
def store_details():
|
|
26
|
+
return {
|
|
27
|
+
"name": "Abstract Challenge Store",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def __iter__(self):
|
|
31
|
+
raise NotImplementedError("Must implement `__iter__` method.")
|
|
32
|
+
|
|
33
|
+
def __len__(self):
|
|
34
|
+
raise NotImplementedError("Must implement `__len__` method.")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
from .ChallengeStore import ChallengeStore
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
class FileSystemChallengeStore(ChallengeStore):
|
|
6
|
+
"""
|
|
7
|
+
Filesystem implementation of the ChallengeStore.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, directory: str):
|
|
11
|
+
self.directory = directory
|
|
12
|
+
os.makedirs(self.directory, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
def supports_domain(self, domain: str) -> bool:
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
18
|
+
file_path = os.path.join(self.directory, key)
|
|
19
|
+
with open(file_path, "w") as file:
|
|
20
|
+
file.write(value)
|
|
21
|
+
|
|
22
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
23
|
+
file_path = os.path.join(self.directory, key)
|
|
24
|
+
if not os.path.exists(file_path):
|
|
25
|
+
return None
|
|
26
|
+
with open(file_path, "r") as file:
|
|
27
|
+
return file.read()
|
|
28
|
+
|
|
29
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
30
|
+
file_path = os.path.join(self.directory, key)
|
|
31
|
+
if os.path.exists(file_path):
|
|
32
|
+
os.remove(file_path)
|
|
33
|
+
|
|
34
|
+
def __iter__(self):
|
|
35
|
+
return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
|
|
36
|
+
|
|
37
|
+
def __len__(self):
|
|
38
|
+
return len([f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f))])
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
from .ChallengeStore import ChallengeStore
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
class FileSystemChallengeStore(ChallengeStore):
|
|
6
|
+
"""
|
|
7
|
+
Filesystem implementation of the ChallengeStore.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, directory: str):
|
|
11
|
+
self.directory = directory
|
|
12
|
+
os.makedirs(self.directory, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
def supports_domain(self, domain: str) -> bool:
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
18
|
+
file_path = os.path.join(self.directory, key)
|
|
19
|
+
with open(file_path, "w") as file:
|
|
20
|
+
file.write(value)
|
|
21
|
+
|
|
22
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
23
|
+
file_path = os.path.join(self.directory, key)
|
|
24
|
+
if not os.path.exists(file_path):
|
|
25
|
+
return None
|
|
26
|
+
with open(file_path, "r") as file:
|
|
27
|
+
return file.read()
|
|
28
|
+
|
|
29
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
30
|
+
file_path = os.path.join(self.directory, key)
|
|
31
|
+
if os.path.exists(file_path):
|
|
32
|
+
os.remove(file_path)
|
|
33
|
+
|
|
34
|
+
def __iter__(self):
|
|
35
|
+
return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
|
|
36
|
+
|
|
37
|
+
def __len__(self):
|
|
38
|
+
return len([f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f))])
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .ChallengeStore import ChallengeStore
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InMemoryChallengeStore(ChallengeStore):
|
|
5
|
+
"""
|
|
6
|
+
In-memory implementation of the ChallengeStore.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.challenges = {}
|
|
11
|
+
|
|
12
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
13
|
+
self.challenges[key] = value
|
|
14
|
+
|
|
15
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
16
|
+
return self.challenges.get(key, "")
|
|
17
|
+
|
|
18
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
19
|
+
if key in self.challenges:
|
|
20
|
+
del self.challenges[key]
|
|
21
|
+
|
|
22
|
+
def __iter__(self):
|
|
23
|
+
return iter(self.challenges)
|
|
24
|
+
|
|
25
|
+
def __len__(self):
|
|
26
|
+
return len(self.challenges)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .ChallengeStore import ChallengeStore
|
|
2
|
+
from .InmemoryChallengeStore import InMemoryChallengeStore
|
|
3
|
+
import os
|
|
4
|
+
from .FileSystemChallengeStore import FileSystemChallengeStore
|
|
5
|
+
from .dns import CloudflareChallengeStore,DigitalOceanChallengeStore
|
|
6
|
+
|
|
7
|
+
def get_challenge_store():
|
|
8
|
+
"""
|
|
9
|
+
Factory function to determine the type of store based on environment variables.
|
|
10
|
+
|
|
11
|
+
Environment Variables:
|
|
12
|
+
- `CHALLENGE_STORE_TYPE`: Can be "memory" or "filesystem".
|
|
13
|
+
- `CHALLENGE_STORE_DIR`: Directory for filesystem-based store. Defaults to "./challenges".
|
|
14
|
+
"""
|
|
15
|
+
store_type = os.getenv("CHALLENGE_STORE_TYPE", "filesystem").lower()
|
|
16
|
+
directory = os.getenv("CHALLENGE_STORE_DIR", "./challenges")
|
|
17
|
+
|
|
18
|
+
if store_type == "memory":
|
|
19
|
+
return InMemoryChallengeStore()
|
|
20
|
+
elif store_type == "filesystem":
|
|
21
|
+
return FileSystemChallengeStore(directory)
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError(f"Unknown CHALLENGE_STORE_TYPE: {store_type}")
|
|
24
|
+
|
|
25
|
+
|