certapi 0.1.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/MANIFEST.in +1 -0
- certapi-0.1.0/PKG-INFO +45 -0
- certapi-0.1.0/README.md +30 -0
- certapi-0.1.0/pyproject.toml +16 -0
- certapi-0.1.0/setup.cfg +4 -0
- certapi-0.1.0/setup.py +25 -0
- certapi-0.1.0/src/certapi/Acme.py +386 -0
- certapi-0.1.0/src/certapi/__init__.py +5 -0
- certapi-0.1.0/src/certapi/certauthority.py +154 -0
- certapi-0.1.0/src/certapi/challenge.py +121 -0
- certapi-0.1.0/src/certapi/crypto.py +222 -0
- certapi-0.1.0/src/certapi/crypto_classes.py +135 -0
- certapi-0.1.0/src/certapi/custom_certauthority.py +94 -0
- certapi-0.1.0/src/certapi/db.py +365 -0
- certapi-0.1.0/src/certapi/util.py +19 -0
- certapi-0.1.0/src/certapi.egg-info/PKG-INFO +45 -0
- certapi-0.1.0/src/certapi.egg-info/SOURCES.txt +19 -0
- certapi-0.1.0/src/certapi.egg-info/dependency_links.txt +1 -0
- certapi-0.1.0/src/certapi.egg-info/requires.txt +2 -0
- certapi-0.1.0/src/certapi.egg-info/top_level.txt +1 -0
- certapi-0.1.0/tests/test_custom_certauthority.py +43 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include README.md
|
certapi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: certapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python Package for managing keys, request SSL certificates from ACME.
|
|
5
|
+
Home-page: https://github.com/mesudip/certmanager
|
|
6
|
+
Author: Sudip Bhattarai
|
|
7
|
+
Author-email: sudipbhattarai100@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: cryptography
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
|
|
16
|
+
# CertManager
|
|
17
|
+
|
|
18
|
+
CertManager is a Python package for requesting SSL certificates from ACME.
|
|
19
|
+
This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
You can install CertManager using pip:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install certapi
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Example Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import json
|
|
33
|
+
from certmanager import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
|
|
34
|
+
|
|
35
|
+
key_store = FilesystemKeyStore("data")
|
|
36
|
+
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
|
|
37
|
+
|
|
38
|
+
certAuthority = CertAuthority(challenge_store, key_store)
|
|
39
|
+
certAuthority.setup()
|
|
40
|
+
|
|
41
|
+
(response,_) = certAuthority.obtainCert("example.com")
|
|
42
|
+
|
|
43
|
+
json.dumps(response.__json__(),indent=2)
|
|
44
|
+
|
|
45
|
+
```
|
certapi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# CertManager
|
|
2
|
+
|
|
3
|
+
CertManager is a Python package for requesting SSL certificates from ACME.
|
|
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
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
You can install CertManager using pip:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install certapi
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Example Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import json
|
|
18
|
+
from certmanager import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
|
|
19
|
+
|
|
20
|
+
key_store = FilesystemKeyStore("data")
|
|
21
|
+
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
|
|
22
|
+
|
|
23
|
+
certAuthority = CertAuthority(challenge_store, key_store)
|
|
24
|
+
certAuthority.setup()
|
|
25
|
+
|
|
26
|
+
(response,_) = certAuthority.obtainCert("example.com")
|
|
27
|
+
|
|
28
|
+
json.dumps(response.__json__(),indent=2)
|
|
29
|
+
|
|
30
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[tool.black]
|
|
2
|
+
line-length = 120
|
|
3
|
+
|
|
4
|
+
[tool.pytest.ini_options]
|
|
5
|
+
|
|
6
|
+
minversion = "6.0"
|
|
7
|
+
addopts = "-ra -q"
|
|
8
|
+
testpaths = [
|
|
9
|
+
"tests",
|
|
10
|
+
]
|
|
11
|
+
env_files =[".env", ".test.env"]
|
|
12
|
+
pythonpath = "src"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["setuptools>=42"]
|
|
16
|
+
build-backend = "setuptools.build_meta"
|
certapi-0.1.0/setup.cfg
ADDED
certapi-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="certapi",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(where="src"),
|
|
7
|
+
package_dir={"": "src"},
|
|
8
|
+
install_requires=[
|
|
9
|
+
"cryptography",
|
|
10
|
+
"requests",
|
|
11
|
+
],
|
|
12
|
+
include_package_data=True,
|
|
13
|
+
description="Python Package for managing keys, request SSL certificates from ACME.",
|
|
14
|
+
long_description=open("README.md").read(),
|
|
15
|
+
long_description_content_type="text/markdown",
|
|
16
|
+
author="Sudip Bhattarai",
|
|
17
|
+
author_email="sudipbhattarai100@gmail.com",
|
|
18
|
+
url="https://github.com/mesudip/certmanager",
|
|
19
|
+
classifiers=[
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
],
|
|
24
|
+
python_requires=">=3.6",
|
|
25
|
+
)
|
|
@@ -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
|
+
)
|
|
@@ -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}
|