certapi 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {certapi-0.1.1 → certapi-0.2.0}/PKG-INFO +1 -3
- {certapi-0.1.1 → certapi-0.2.0}/setup.py +1 -1
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/Acme.py +21 -9
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/__init__.py +1 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/certauthority.py +63 -8
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/challenge.py +13 -31
- certapi-0.2.0/src/certapi/cloudflare_challenge_store.py +57 -0
- certapi-0.2.0/src/certapi/cloudflare_client.py +137 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/crypto.py +20 -1
- certapi-0.2.0/src/certapi/crypto_classes.py +240 -0
- certapi-0.2.0/src/certapi/custom_certauthority.py +127 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/db.py +1 -1
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi.egg-info/PKG-INFO +1 -3
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi.egg-info/SOURCES.txt +3 -2
- certapi-0.1.1/src/certapi/crypto_classes.py +0 -135
- certapi-0.1.1/src/certapi/custom_certauthority.py +0 -94
- certapi-0.1.1/tests/test_custom_certauthority.py +0 -43
- {certapi-0.1.1 → certapi-0.2.0}/MANIFEST.in +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/README.md +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/pyproject.toml +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/setup.cfg +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi/util.py +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.1.1 → certapi-0.2.0}/src/certapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python Package for managing keys, request SSL certificates from ACME.
|
|
5
5
|
Home-page: https://github.com/mesudip/certapi
|
|
6
6
|
Author: Sudip Bhattarai
|
|
@@ -10,8 +10,6 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Requires-Python: >=3.6
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
-
Requires-Dist: cryptography
|
|
14
|
-
Requires-Dist: requests
|
|
15
13
|
|
|
16
14
|
# CertApi
|
|
17
15
|
|
|
@@ -8,11 +8,11 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
|
8
8
|
from cryptography.x509 import CertificateSigningRequest, Certificate
|
|
9
9
|
from . import crypto
|
|
10
10
|
import requests
|
|
11
|
-
from .crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name
|
|
11
|
+
from .crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name, sign_for_jws
|
|
12
12
|
from .util import b64_encode, b64_string
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
acme_url = os.environ.get("LETSENCRYPT_API", None)
|
|
14
|
+
acme_url = os.environ.get("LETSENCRYPT_API", "https://acme-staging-v02.api.letsencrypt.org/directory")
|
|
15
|
+
# acme_url = os.environ.get("LETSENCRYPT_API", None)
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class AcmeError(Exception):
|
|
@@ -177,6 +177,7 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
|
|
|
177
177
|
step,
|
|
178
178
|
)
|
|
179
179
|
if 199 <= res.status_code > 299:
|
|
180
|
+
|
|
180
181
|
[print(x, y) for (x, y) in res.headers.items()]
|
|
181
182
|
print("Response:", res.text)
|
|
182
183
|
json_data = None
|
|
@@ -211,8 +212,9 @@ class Acme:
|
|
|
211
212
|
self.account_key = account_key
|
|
212
213
|
# json web key format for public key
|
|
213
214
|
self.jwk = jwk(self.account_key)
|
|
215
|
+
print(self.jwk)
|
|
214
216
|
self.nonce = []
|
|
215
|
-
self.acme_url = url if url else self.
|
|
217
|
+
self.acme_url = url if url else self.URL_STAGING
|
|
216
218
|
self.key_id = None
|
|
217
219
|
self.directory = None
|
|
218
220
|
self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
|
|
@@ -267,7 +269,7 @@ class Acme:
|
|
|
267
269
|
payload = {
|
|
268
270
|
"protected": protectedb64.decode("utf-8"),
|
|
269
271
|
"payload": payload64.decode("utf-8"),
|
|
270
|
-
"signature": b64_string(
|
|
272
|
+
"signature": b64_string(sign_for_jws(self.account_key, b".".join([protectedb64, payload64]))),
|
|
271
273
|
}
|
|
272
274
|
try:
|
|
273
275
|
|
|
@@ -357,6 +359,7 @@ class Challenge:
|
|
|
357
359
|
challenge = self.get_challenge()
|
|
358
360
|
self.token = challenge["token"]
|
|
359
361
|
self.verified = challenge["status"] == "valid"
|
|
362
|
+
self.domain = data["identifier"]["value"] # Add domain attribute
|
|
360
363
|
|
|
361
364
|
jwk_json = json.dumps(self._acme.jwk, sort_keys=True, separators=(",", ":"))
|
|
362
365
|
thumbprint = b64_encode(digest_sha256(jwk_json.encode("utf8")))
|
|
@@ -364,9 +367,11 @@ class Challenge:
|
|
|
364
367
|
|
|
365
368
|
self.url = "http://{0}/.well-known/acme-challenge/{1}".format(data["identifier"]["value"], self.token)
|
|
366
369
|
|
|
367
|
-
def verify(self) -> bool:
|
|
370
|
+
def verify(self, dns=False) -> bool:
|
|
368
371
|
if not self.verified:
|
|
369
|
-
response = self._acme._signed_req(
|
|
372
|
+
response = self._acme._signed_req(
|
|
373
|
+
self.get_challenge(key="dns-01" if dns else "http-01")["url"], {}, step="Verify Challenge", throw=False
|
|
374
|
+
)
|
|
370
375
|
if response.status_code == 200 and response.json()["status"] == "valid":
|
|
371
376
|
self.verified = True
|
|
372
377
|
return True
|
|
@@ -398,9 +403,16 @@ class Challenge:
|
|
|
398
403
|
return False
|
|
399
404
|
|
|
400
405
|
def get_challenge(self, key="http-01"):
|
|
401
|
-
|
|
406
|
+
challenges = self._data["challenges"]
|
|
407
|
+
for method in challenges:
|
|
402
408
|
if method["type"] == key:
|
|
403
409
|
return method
|
|
410
|
+
if len(challenges) == 1:
|
|
411
|
+
return challenges[0]
|
|
412
|
+
|
|
413
|
+
ch_types = [x["type"] for x in self._data["challenges"]]
|
|
404
414
|
raise AcmeError(
|
|
405
|
-
"'
|
|
415
|
+
f"'{key}' not found in challenges. available:{str(ch_types)}",
|
|
416
|
+
{"response": self._data["challenges"]},
|
|
417
|
+
"Acme Challenge Verification",
|
|
406
418
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
|
|
2
2
|
from .certauthority import CertAuthority
|
|
3
|
+
from .custom_certauthority import CertificateIssuer
|
|
3
4
|
from .crypto import gen_key_ed25519, create_csr
|
|
4
5
|
from .db import KeyStore, FilesystemKeyStore, SqliteKeyStore, PostgresKeyStore
|
|
5
6
|
from .challenge import InMemoryChallengeStore, FileSystemChallengeStore
|
|
@@ -6,19 +6,35 @@ import requests
|
|
|
6
6
|
from cryptography.x509 import Certificate
|
|
7
7
|
from requests import Response
|
|
8
8
|
|
|
9
|
+
from typing import List, Union, Callable, Tuple, Dict
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
from cryptography.x509 import Certificate
|
|
15
|
+
from requests import Response
|
|
16
|
+
|
|
9
17
|
from . import Acme, Challenge, Order
|
|
10
18
|
from . import crypto
|
|
11
19
|
from . import challenge
|
|
12
|
-
from .crypto import cert_to_pem, key_to_pem
|
|
20
|
+
from .crypto import cert_to_pem, key_to_pem, digest_sha256
|
|
13
21
|
from .crypto_classes import Key
|
|
14
22
|
from .db import KeyStore
|
|
23
|
+
from .util import b64_string
|
|
15
24
|
|
|
16
25
|
|
|
17
26
|
class CertAuthority:
|
|
18
|
-
def __init__(
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
challenge_store: challenge.ChallengeStore,
|
|
30
|
+
key_store: KeyStore,
|
|
31
|
+
acme_url=None,
|
|
32
|
+
dns_stores: List[challenge.ChallengeStore] = None,
|
|
33
|
+
):
|
|
19
34
|
self.acme = Acme(key_store.account_key, url=acme_url)
|
|
20
35
|
self.key_store = key_store
|
|
21
36
|
self.challengesStore: challenge.ChallengeStore = challenge_store
|
|
37
|
+
self.dns_stores = dns_stores if dns_stores is not None else []
|
|
22
38
|
|
|
23
39
|
def setup(self):
|
|
24
40
|
self.acme.setup()
|
|
@@ -35,18 +51,51 @@ class CertAuthority:
|
|
|
35
51
|
existing = {c[0]: c[1] for c in [(h, self.key_store.get_cert(h)) for h in host] if c[1] is not None}
|
|
36
52
|
missing = [h for h in host if h not in existing]
|
|
37
53
|
if len(missing) > 0:
|
|
54
|
+
has_wildcard = False
|
|
55
|
+
# Determine which challenge store to use
|
|
56
|
+
challenge_store_to_use = self.challengesStore
|
|
57
|
+
for h in missing:
|
|
58
|
+
if h.startswith("*."): # Wildcard domain
|
|
59
|
+
has_wildcard = True
|
|
60
|
+
found_dns_store = False
|
|
61
|
+
|
|
62
|
+
for dns_store in self.dns_stores:
|
|
63
|
+
if dns_store.has_domain(h.lstrip("*.")): # Check if the DNS store can handle the base domain
|
|
64
|
+
challenge_store_to_use = dns_store
|
|
65
|
+
found_dns_store = True
|
|
66
|
+
break
|
|
67
|
+
if not found_dns_store:
|
|
68
|
+
raise Exception(f"No DNS challenge store found for wildcard domain {h}")
|
|
69
|
+
break # Assuming all domains in a single request will use the same challenge type
|
|
70
|
+
|
|
38
71
|
private_key = crypto.gen_key_secp256r1()
|
|
39
72
|
order = self.acme.create_authorized_order(missing)
|
|
40
73
|
|
|
41
74
|
challenges = order.remaining_challenges()
|
|
75
|
+
|
|
42
76
|
for c in challenges:
|
|
43
77
|
print("[ Challenge ]", c.token, "=", c.authorization_key)
|
|
44
|
-
|
|
78
|
+
# For DNS-01 challenges, the key should be _acme-challenge.<domain>
|
|
79
|
+
challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
|
|
80
|
+
|
|
81
|
+
# For DNS-01 challenges, the value is the SHA256 hash of the authorization_key, base64url encoded
|
|
82
|
+
challenge_value = (
|
|
83
|
+
b64_string(digest_sha256(c.authorization_key.encode("utf8")))
|
|
84
|
+
if has_wildcard
|
|
85
|
+
else c.authorization_key
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
challenge_store_to_use.save_challenge(challenge_name, challenge_value, c.domain)
|
|
89
|
+
|
|
90
|
+
# Add an initial sleep to allow DNS propagation
|
|
91
|
+
if has_wildcard:
|
|
92
|
+
print("Waiting for DNS propagation (10 seconds)...")
|
|
93
|
+
time.sleep(10)
|
|
94
|
+
|
|
45
95
|
for c in challenges:
|
|
46
96
|
# c.self_verify()
|
|
47
|
-
c.verify()
|
|
48
|
-
|
|
49
|
-
end = time.time() + 40 # max 12 seconds
|
|
97
|
+
c.verify(dns=has_wildcard)
|
|
98
|
+
end = time.time() + 60 # Increase overall timeout
|
|
50
99
|
source: List[Challenge] = [x for x in challenges]
|
|
51
100
|
sink = []
|
|
52
101
|
counter = 1
|
|
@@ -61,8 +110,6 @@ class CertAuthority:
|
|
|
61
110
|
if len(sink) > 0:
|
|
62
111
|
time.sleep(3)
|
|
63
112
|
source, sink, counter = sink, [], counter + 1
|
|
64
|
-
else:
|
|
65
|
-
print("Order is already Ready.")
|
|
66
113
|
csr = crypto.create_csr(private_key, missing[0], missing[1:])
|
|
67
114
|
order.finalize(csr)
|
|
68
115
|
|
|
@@ -75,9 +122,17 @@ class CertAuthority:
|
|
|
75
122
|
key_id = self.key_store.save_key(private_key, missing[0])
|
|
76
123
|
cert_id = self.key_store.save_cert(key_id, certificate, missing)
|
|
77
124
|
issued_cert = IssuedCert(key_to_pem(private_key), certificate, missing)
|
|
125
|
+
# Clean up challenges after successful certificate issuance
|
|
126
|
+
for c in challenges:
|
|
127
|
+
challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
|
|
128
|
+
challenge_store_to_use.delete_challenge(challenge_name, c.domain)
|
|
78
129
|
return createExistingResponse(existing, [issued_cert])
|
|
79
130
|
elif order.status == "processing":
|
|
80
131
|
if count == 0:
|
|
132
|
+
# Clean up challenges if timeout occurs
|
|
133
|
+
for c in challenges:
|
|
134
|
+
challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
|
|
135
|
+
challenge_store_to_use.delete_challenge(challenge_name, c.domain)
|
|
81
136
|
return None
|
|
82
137
|
return obtain_cert()
|
|
83
138
|
return None
|
|
@@ -2,28 +2,19 @@ import os
|
|
|
2
2
|
from collections.abc import MutableMapping
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
class ChallengeStore
|
|
5
|
+
class ChallengeStore:
|
|
6
6
|
"""
|
|
7
7
|
Abstract base class for a challenge store.
|
|
8
|
-
Provides dictionary-like behavior by inheriting from MutableMapping.
|
|
9
8
|
"""
|
|
10
9
|
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def __getitem__(self, key):
|
|
15
|
-
value = self.get_challenge(key)
|
|
16
|
-
if value is None:
|
|
17
|
-
raise KeyError(key)
|
|
18
|
-
return value
|
|
10
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
11
|
+
raise NotImplementedError("Must implement `save_challenge` method.")
|
|
19
12
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
raise KeyError(key)
|
|
23
|
-
self.delete_challenge(key)
|
|
13
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
14
|
+
raise NotImplementedError("Must implement `get_challenge` method.")
|
|
24
15
|
|
|
25
|
-
def
|
|
26
|
-
|
|
16
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
17
|
+
raise NotImplementedError("Must implement `delete_challenge` method.")
|
|
27
18
|
|
|
28
19
|
def __iter__(self):
|
|
29
20
|
raise NotImplementedError("Must implement `__iter__` method.")
|
|
@@ -31,15 +22,6 @@ class ChallengeStore(MutableMapping):
|
|
|
31
22
|
def __len__(self):
|
|
32
23
|
raise NotImplementedError("Must implement `__len__` method.")
|
|
33
24
|
|
|
34
|
-
def save_challenge(self, key: str, value: str):
|
|
35
|
-
raise NotImplementedError("Must implement `save_challenge` method.")
|
|
36
|
-
|
|
37
|
-
def get_challenge(self, key: str) -> str:
|
|
38
|
-
raise NotImplementedError("Must implement `get_challenge` method.")
|
|
39
|
-
|
|
40
|
-
def delete_challenge(self, key: str):
|
|
41
|
-
raise NotImplementedError("Must implement `delete_challenge` method.")
|
|
42
|
-
|
|
43
25
|
|
|
44
26
|
class InMemoryChallengeStore(ChallengeStore):
|
|
45
27
|
"""
|
|
@@ -49,13 +31,13 @@ class InMemoryChallengeStore(ChallengeStore):
|
|
|
49
31
|
def __init__(self):
|
|
50
32
|
self.challenges = {}
|
|
51
33
|
|
|
52
|
-
def save_challenge(self, key: str, value: str):
|
|
34
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
53
35
|
self.challenges[key] = value
|
|
54
36
|
|
|
55
|
-
def get_challenge(self, key: str) -> str:
|
|
37
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
56
38
|
return self.challenges.get(key, "")
|
|
57
39
|
|
|
58
|
-
def delete_challenge(self, key: str):
|
|
40
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
59
41
|
if key in self.challenges:
|
|
60
42
|
del self.challenges[key]
|
|
61
43
|
|
|
@@ -75,19 +57,19 @@ class FileSystemChallengeStore(ChallengeStore):
|
|
|
75
57
|
self.directory = directory
|
|
76
58
|
os.makedirs(self.directory, exist_ok=True)
|
|
77
59
|
|
|
78
|
-
def save_challenge(self, key: str, value: str):
|
|
60
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
79
61
|
file_path = os.path.join(self.directory, key)
|
|
80
62
|
with open(file_path, "w") as file:
|
|
81
63
|
file.write(value)
|
|
82
64
|
|
|
83
|
-
def get_challenge(self, key: str) -> str:
|
|
65
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
84
66
|
file_path = os.path.join(self.directory, key)
|
|
85
67
|
if not os.path.exists(file_path):
|
|
86
68
|
return None
|
|
87
69
|
with open(file_path, "r") as file:
|
|
88
70
|
return file.read()
|
|
89
71
|
|
|
90
|
-
def delete_challenge(self, key: str):
|
|
72
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
91
73
|
file_path = os.path.join(self.directory, key)
|
|
92
74
|
if os.path.exists(file_path):
|
|
93
75
|
os.remove(file_path)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import MutableMapping
|
|
3
|
+
from certapi.challenge import ChallengeStore
|
|
4
|
+
from certapi.cloudflare_client import Cloudflare
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CloudflareChallengeStore(ChallengeStore):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.cloudflare = Cloudflare()
|
|
10
|
+
self.challenges_map = {} # Stores key: record_id (still needed for deletion)
|
|
11
|
+
|
|
12
|
+
def has_domain(self, domain: str) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Checks if the Cloudflare account has access to the given domain (or its base domain)
|
|
15
|
+
as a registered zone.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
self.cloudflare.determine_registered_domain(domain)
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def save_challenge(self, key: str, value: str, domain=None):
|
|
24
|
+
# key example: _acme-challenge.sub.example.com
|
|
25
|
+
# value example: ACME_CHALLENGE_TOKEN
|
|
26
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
27
|
+
|
|
28
|
+
record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
|
|
29
|
+
self.challenges_map[key] = record_id
|
|
30
|
+
print(f"CloudflareChallengeStore: Saved challenge for {key} with record ID {record_id}")
|
|
31
|
+
|
|
32
|
+
def get_challenge(self, key: str, domain: str) -> str:
|
|
33
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
34
|
+
records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
|
|
35
|
+
for record in records:
|
|
36
|
+
if record["name"] == key:
|
|
37
|
+
return record["content"]
|
|
38
|
+
return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
|
|
39
|
+
|
|
40
|
+
def delete_challenge(self, key: str, domain: str):
|
|
41
|
+
if key not in self.challenges_map:
|
|
42
|
+
raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
|
|
43
|
+
|
|
44
|
+
record_id = self.challenges_map[key]
|
|
45
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
46
|
+
self.cloudflare.delete_record(record=record_id, domain=base_domain)
|
|
47
|
+
del self.challenges_map[key]
|
|
48
|
+
print(f"CloudflareChallengeStore: Deleted challenge for {key} with record ID {record_id}")
|
|
49
|
+
|
|
50
|
+
def __iter__(self):
|
|
51
|
+
# This is tricky as we can't easily iterate all challenges across all domains
|
|
52
|
+
# If the user wants a full API-driven iteration, they need to clarify how to get all domains.
|
|
53
|
+
return iter(self.challenges_map)
|
|
54
|
+
|
|
55
|
+
def __len__(self):
|
|
56
|
+
# Similar to __iter__, this will count challenges managed by this store instance.
|
|
57
|
+
return len(self.challenges_map)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from os import getenv
|
|
4
|
+
from urllib.request import urlopen, Request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cloudflare(object):
|
|
8
|
+
name = "cloudflare"
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.token = getenv("CLOUDFLARE_API_TOKEN")
|
|
12
|
+
self.account_id = getenv("CLOUDFLARE_ACCOUNT_ID")
|
|
13
|
+
self.api = "https://api.cloudflare.com/client/v4"
|
|
14
|
+
if not self.token:
|
|
15
|
+
raise Exception("CLOUDFLARE_API_TOKEN not found in environment")
|
|
16
|
+
|
|
17
|
+
self._zones_cache = None
|
|
18
|
+
self._zones_cache_time = 0 # Unix timestamp of last cache update
|
|
19
|
+
|
|
20
|
+
def _cloudflare_headers(self):
|
|
21
|
+
return {"Content-Type": "application/json", "Authorization": "Bearer " + self.token}
|
|
22
|
+
|
|
23
|
+
def _get_zones(self):
|
|
24
|
+
"""Fetch and cache Cloudflare zones"""
|
|
25
|
+
# Cache for 1 day (86400 seconds)
|
|
26
|
+
if self._zones_cache and (time.time() - self._zones_cache_time) < 86400:
|
|
27
|
+
return self._zones_cache
|
|
28
|
+
|
|
29
|
+
request_headers = self._cloudflare_headers()
|
|
30
|
+
api_url = "{0}/zones?per_page=50".format(self.api)
|
|
31
|
+
response = urlopen(Request(api_url, headers=request_headers))
|
|
32
|
+
if response.getcode() != 200:
|
|
33
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
34
|
+
|
|
35
|
+
zones = json.loads(response.read().decode("utf8"))["result"]
|
|
36
|
+
self._zones_cache = zones
|
|
37
|
+
self._zones_cache_time = time.time()
|
|
38
|
+
return zones
|
|
39
|
+
|
|
40
|
+
def _get_zone_id(self, domain):
|
|
41
|
+
"""Determine Cloudflare Zone ID for a given domain"""
|
|
42
|
+
zones = self._get_zones()
|
|
43
|
+
for zone in zones:
|
|
44
|
+
if zone["name"] == domain:
|
|
45
|
+
return zone["id"]
|
|
46
|
+
raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
|
|
47
|
+
|
|
48
|
+
def determine_registered_domain(self, domain: str) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Determine the registered domain in Cloudflare for a given (sub)domain.
|
|
51
|
+
This method iterates through parts of the domain to find a matching Cloudflare zone.
|
|
52
|
+
"""
|
|
53
|
+
parts = domain.split(".")
|
|
54
|
+
err = None
|
|
55
|
+
for i in range(len(parts)):
|
|
56
|
+
potential_domain = ".".join(parts[i:])
|
|
57
|
+
try:
|
|
58
|
+
self._get_zone_id(potential_domain)
|
|
59
|
+
return potential_domain
|
|
60
|
+
except Exception as e:
|
|
61
|
+
err = e
|
|
62
|
+
continue
|
|
63
|
+
if err:
|
|
64
|
+
raise err
|
|
65
|
+
else:
|
|
66
|
+
raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
|
|
67
|
+
|
|
68
|
+
def list_txt_records(self, domain: str, name_filter: str = None) -> list:
|
|
69
|
+
"""
|
|
70
|
+
Lists TXT records for a given domain, optionally filtered by name.
|
|
71
|
+
Returns a list of dictionaries, each representing a TXT record.
|
|
72
|
+
"""
|
|
73
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
74
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
75
|
+
api_url = f"{self.api}/zones/{zone_id}/dns_records?type=TXT"
|
|
76
|
+
if name_filter:
|
|
77
|
+
api_url += f"&name={name_filter}"
|
|
78
|
+
|
|
79
|
+
request_headers = self._cloudflare_headers()
|
|
80
|
+
response = urlopen(Request(api_url, headers=request_headers))
|
|
81
|
+
|
|
82
|
+
if response.getcode() != 200:
|
|
83
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
84
|
+
|
|
85
|
+
result = json.loads(response.read().decode("utf8"))
|
|
86
|
+
if not result.get("success"):
|
|
87
|
+
raise Exception(result.get("errors", "Unknown error listing TXT records"))
|
|
88
|
+
|
|
89
|
+
return result["result"]
|
|
90
|
+
|
|
91
|
+
def create_record(self, name, data, domain):
|
|
92
|
+
"""
|
|
93
|
+
Create DNS record
|
|
94
|
+
Params:
|
|
95
|
+
name, string, record name (e.g., _acme-challenge.example.com)
|
|
96
|
+
data, string, record data (e.g., ACME challenge token)
|
|
97
|
+
domain, string, dns domain (e.g., example.com) - This will be used to determine the registered zone.
|
|
98
|
+
Return:
|
|
99
|
+
record_id, string, created record id
|
|
100
|
+
"""
|
|
101
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
102
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
103
|
+
api_url = "{0}/zones/{1}/dns_records".format(self.api, zone_id)
|
|
104
|
+
request_headers = self._cloudflare_headers()
|
|
105
|
+
request_data = {
|
|
106
|
+
"type": "TXT",
|
|
107
|
+
"name": name,
|
|
108
|
+
"content": data,
|
|
109
|
+
"ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
|
|
110
|
+
"proxied": False,
|
|
111
|
+
}
|
|
112
|
+
response = urlopen(Request(api_url, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
|
|
113
|
+
|
|
114
|
+
if response.getcode() != 200:
|
|
115
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
116
|
+
result = response.read().decode("utf8")
|
|
117
|
+
print("Cloudflare create record", name, result)
|
|
118
|
+
return json.loads(result)["result"]["id"]
|
|
119
|
+
|
|
120
|
+
def delete_record(self, record, domain):
|
|
121
|
+
"""
|
|
122
|
+
Delete DNS record
|
|
123
|
+
Params:
|
|
124
|
+
record, string, record id number
|
|
125
|
+
domain, string, dns domain - This will be used to determine the registered zone.
|
|
126
|
+
"""
|
|
127
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
128
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
129
|
+
api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api, zone_id, record)
|
|
130
|
+
request_headers = self._cloudflare_headers()
|
|
131
|
+
request = Request(api_url, headers=request_headers)
|
|
132
|
+
request.get_method = lambda: "DELETE"
|
|
133
|
+
response = urlopen(request)
|
|
134
|
+
result = response.read().decode("utf8")
|
|
135
|
+
print(f"Delete dns record [{response.getcode()}]", result)
|
|
136
|
+
if response.getcode() != 200:
|
|
137
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
@@ -2,6 +2,7 @@ from typing import List, Union
|
|
|
2
2
|
|
|
3
3
|
from cryptography.hazmat.backends import default_backend
|
|
4
4
|
from cryptography.hazmat.primitives import serialization
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import utils
|
|
5
6
|
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding, ed25519
|
|
6
7
|
from cryptography import x509
|
|
7
8
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
@@ -31,6 +32,10 @@ def gen_key_ed25519():
|
|
|
31
32
|
return ed25519.Ed25519PrivateKey.generate()
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def gen_key_ecdsa():
|
|
36
|
+
return ec.generate_private_key(ec.SECP256R1())
|
|
37
|
+
|
|
38
|
+
|
|
34
39
|
def get_algorithm_name(key):
|
|
35
40
|
if isinstance(key, RSAPrivateKey):
|
|
36
41
|
return "RS256"
|
|
@@ -44,6 +49,8 @@ def get_algorithm_name(key):
|
|
|
44
49
|
return "ES384"
|
|
45
50
|
elif curve_name == "secp521r1":
|
|
46
51
|
return "ES512"
|
|
52
|
+
else:
|
|
53
|
+
raise ValueError(f"Unsupported EC curve: {curve_name}")
|
|
47
54
|
else:
|
|
48
55
|
raise ValueError("Unsupported key type")
|
|
49
56
|
|
|
@@ -132,11 +139,23 @@ def sign(key: Union[RSAPrivateKey, Ed25519PrivateKey, EllipticCurvePrivateKey],
|
|
|
132
139
|
return key.sign(message, ec.ECDSA(hasher))
|
|
133
140
|
|
|
134
141
|
|
|
142
|
+
def sign_for_jws(key, message, hasher=hashes.SHA256()):
|
|
143
|
+
if isinstance(key, EllipticCurvePrivateKey):
|
|
144
|
+
der_sig = key.sign(message, ec.ECDSA(hasher))
|
|
145
|
+
r, s = utils.decode_dss_signature(der_sig)
|
|
146
|
+
num_bytes = (key.curve.key_size + 7) // 8
|
|
147
|
+
r_bytes = r.to_bytes(num_bytes, "big")
|
|
148
|
+
s_bytes = s.to_bytes(num_bytes, "big")
|
|
149
|
+
return r_bytes + s_bytes
|
|
150
|
+
else:
|
|
151
|
+
return sign(key, message, hasher)
|
|
152
|
+
|
|
153
|
+
|
|
135
154
|
def sign_jws(key: RSAPrivateKey, data: object):
|
|
136
155
|
pass
|
|
137
156
|
|
|
138
157
|
|
|
139
|
-
def key_to_der(key:
|
|
158
|
+
def key_to_der(key: RSAPrivateKey | Ed25519PrivateKey | EllipticCurvePrivateKey) -> bytes:
|
|
140
159
|
return key.private_bytes(
|
|
141
160
|
encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=__no_enc
|
|
142
161
|
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec
|
|
3
|
+
from cryptography.hazmat.primitives import serialization, hashes, padding
|
|
4
|
+
from typing import Literal, Optional, List, Union
|
|
5
|
+
from cryptography import x509
|
|
6
|
+
from cryptography.x509.oid import NameOID
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
|
+
|
|
9
|
+
from .crypto import key_to_der, key_to_pem
|
|
10
|
+
from .util import b64_string
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Key(ABC):
|
|
14
|
+
key: rsa.RSAPrivateKey | ed25519.Ed25519PrivateKey | ec.EllipticCurvePrivateKey
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def jwk(self):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def sign(self, message):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def sign_csr(self, csr):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def generate(key_type: Literal["rsa", "ecdsa", "ed25519"]) -> "Key":
|
|
30
|
+
if key_type == "rsa":
|
|
31
|
+
return RSAKey.generate()
|
|
32
|
+
elif key_type == "ecdsa":
|
|
33
|
+
return ECDSAKey.generate()
|
|
34
|
+
elif key_type == "ed25519":
|
|
35
|
+
return Ed25519Key.generate()
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError("Unsupported key type. Use 'rsa' or 'ecdsa'")
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def from_der(der_bytes):
|
|
41
|
+
key = serialization.load_der_private_key(der_bytes, password=None)
|
|
42
|
+
if isinstance(key, rsa.RSAPrivateKey):
|
|
43
|
+
return RSAKey(key)
|
|
44
|
+
elif isinstance(key, ec.EllipticCurvePrivateKey):
|
|
45
|
+
return ECDSAKey(key)
|
|
46
|
+
elif isinstance(key, Ed25519PrivateKey):
|
|
47
|
+
return Ed25519Key(key)
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError("Unsupported key type")
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def from_pem(der_bytes):
|
|
53
|
+
key = serialization.load_pem_private_key(der_bytes, password=None)
|
|
54
|
+
if isinstance(key, rsa.RSAPrivateKey):
|
|
55
|
+
return RSAKey(key)
|
|
56
|
+
elif isinstance(key, ec.EllipticCurvePrivateKey):
|
|
57
|
+
return ECDSAKey(key)
|
|
58
|
+
elif isinstance(key, Ed25519PrivateKey):
|
|
59
|
+
return Ed25519Key(key)
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError("Unsupported key type")
|
|
62
|
+
|
|
63
|
+
def to_der(self) -> bytes:
|
|
64
|
+
return key_to_der(self.key)
|
|
65
|
+
|
|
66
|
+
def to_pem(self) -> bytes:
|
|
67
|
+
return key_to_pem(self.key)
|
|
68
|
+
|
|
69
|
+
def _build_name(self, fields: dict, include_user_id=False, domain=None) -> x509.Name:
|
|
70
|
+
name_attrs = []
|
|
71
|
+
field_map = {
|
|
72
|
+
"country": NameOID.COUNTRY_NAME,
|
|
73
|
+
"state": NameOID.STATE_OR_PROVINCE_NAME,
|
|
74
|
+
"locality": NameOID.LOCALITY_NAME,
|
|
75
|
+
"organization": NameOID.ORGANIZATION_NAME,
|
|
76
|
+
"common_name": NameOID.COMMON_NAME,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for key, oid in field_map.items():
|
|
80
|
+
value = fields.get(key)
|
|
81
|
+
if value:
|
|
82
|
+
name_attrs.append(x509.NameAttribute(oid, value))
|
|
83
|
+
|
|
84
|
+
if include_user_id:
|
|
85
|
+
user_id = fields.get("user_id") or domain
|
|
86
|
+
if user_id:
|
|
87
|
+
name_attrs.append(x509.NameAttribute(NameOID.USER_ID, user_id))
|
|
88
|
+
|
|
89
|
+
return x509.Name(name_attrs)
|
|
90
|
+
|
|
91
|
+
def create_csr(
|
|
92
|
+
self,
|
|
93
|
+
domain: str,
|
|
94
|
+
alt_names: List[str] = (),
|
|
95
|
+
country: Optional[str] = None,
|
|
96
|
+
state: Optional[str] = None,
|
|
97
|
+
locality: Optional[str] = None,
|
|
98
|
+
organization: Optional[str] = None,
|
|
99
|
+
user_id: Optional[str] = None,
|
|
100
|
+
) -> x509.CertificateSigningRequest:
|
|
101
|
+
"""
|
|
102
|
+
Create a Certificate Signing Request (CSR) with the specified parameters.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
domain: The common name (CN) for the CSR.
|
|
106
|
+
alt_names: List of Subject Alternative Names (SAN) for the CSR.
|
|
107
|
+
country: Country name for the subject.
|
|
108
|
+
state: State or province name for the subject.
|
|
109
|
+
locality: Locality name for the subject.
|
|
110
|
+
organization: Organization name for the subject.
|
|
111
|
+
user_id: Optional user ID to include in the subject.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
x509.CertificateSigningRequest: The generated CSR.
|
|
115
|
+
"""
|
|
116
|
+
# Build subject fields
|
|
117
|
+
subject_fields = {
|
|
118
|
+
"country": country,
|
|
119
|
+
"state": state,
|
|
120
|
+
"locality": locality,
|
|
121
|
+
"organization": organization,
|
|
122
|
+
"common_name": domain,
|
|
123
|
+
"user_id": user_id or domain,
|
|
124
|
+
}
|
|
125
|
+
subject = self._build_name(subject_fields, include_user_id=True, domain=domain)
|
|
126
|
+
|
|
127
|
+
# Build CSR with optional SAN extension
|
|
128
|
+
csr_builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
|
|
129
|
+
if alt_names:
|
|
130
|
+
csr_builder = csr_builder.add_extension(
|
|
131
|
+
x509.SubjectAlternativeName([x509.DNSName(name) for name in alt_names]),
|
|
132
|
+
critical=False,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Sign the CSR using the subclass-specific signing method
|
|
136
|
+
return self.sign_csr(csr_builder)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RSAKey(Key):
|
|
140
|
+
def __init__(self, key: rsa.RSAPrivateKey, hasher=hashes.SHA256()):
|
|
141
|
+
self.key = key
|
|
142
|
+
self.hasher = hasher
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def generate():
|
|
146
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
147
|
+
return RSAKey(key)
|
|
148
|
+
|
|
149
|
+
def jwk(self):
|
|
150
|
+
public = self.key.public_key().public_numbers()
|
|
151
|
+
return {
|
|
152
|
+
"e": b64_string((public.e).to_bytes((public.e.bit_length() + 7) // 8, "big")),
|
|
153
|
+
"kty": "RSA",
|
|
154
|
+
"n": b64_string((public.n).to_bytes((public.n.bit_length() + 7) // 8, "big")),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def sign(self, message):
|
|
158
|
+
return self.key.sign(message, padding.PKCS1v15(), self.hasher)
|
|
159
|
+
|
|
160
|
+
def sign_csr(self, csr):
|
|
161
|
+
return csr.sign(self.key, self.hasher)
|
|
162
|
+
|
|
163
|
+
def algorithm_name(self):
|
|
164
|
+
return "RS" + str(self.hasher.digest_size * 8)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Ed25519Key(Key):
|
|
168
|
+
def __init__(self, key: ed25519.Ed25519PrivateKey):
|
|
169
|
+
self.key = key
|
|
170
|
+
self.keyid = "e"
|
|
171
|
+
public = self.key.public_key().public_bytes(
|
|
172
|
+
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
|
173
|
+
)
|
|
174
|
+
self.jwk = {
|
|
175
|
+
"crv": "Ed25519",
|
|
176
|
+
"kty": "OKP",
|
|
177
|
+
"x": b64_string(public),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def generate():
|
|
182
|
+
key = ed25519.Ed25519PrivateKey.generate()
|
|
183
|
+
return Ed25519Key(key)
|
|
184
|
+
|
|
185
|
+
def jwk(self):
|
|
186
|
+
return self.jwk
|
|
187
|
+
|
|
188
|
+
def sign(self, message):
|
|
189
|
+
return self.key.sign(message)
|
|
190
|
+
|
|
191
|
+
def sign_csr(self, csr):
|
|
192
|
+
return csr.sign(self.key, None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ECDSAKey(Key):
|
|
196
|
+
def __init__(self, key: ec.EllipticCurvePrivateKey):
|
|
197
|
+
self.key = key
|
|
198
|
+
public_key = self.key.public_key()
|
|
199
|
+
public_numbers = public_key.public_numbers()
|
|
200
|
+
self.jwk = {
|
|
201
|
+
"kty": "EC",
|
|
202
|
+
"crv": self.key.curve.name,
|
|
203
|
+
"x": b64_string(public_numbers.x.to_bytes((public_numbers.x.bit_length() + 7) // 8, "big")),
|
|
204
|
+
"y": b64_string(public_numbers.y.to_bytes((public_numbers.y.bit_length() + 7) // 8, "big")),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def generate():
|
|
209
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
210
|
+
return ECDSAKey(key)
|
|
211
|
+
|
|
212
|
+
def jwk(self):
|
|
213
|
+
return self.jwk
|
|
214
|
+
|
|
215
|
+
def algorithm_name(self):
|
|
216
|
+
return self.key.curve.name
|
|
217
|
+
|
|
218
|
+
def sign(self, message):
|
|
219
|
+
key_size = self.key.curve.key_size
|
|
220
|
+
if key_size == 256:
|
|
221
|
+
algorithm = hashes.SHA256()
|
|
222
|
+
elif key_size == 384:
|
|
223
|
+
algorithm = hashes.SHA384()
|
|
224
|
+
elif key_size == 521:
|
|
225
|
+
algorithm = hashes.SHA512()
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(f"Unsupported curve with key size {key_size}")
|
|
228
|
+
return self.key.sign(message, ec.ECDSA(algorithm))
|
|
229
|
+
|
|
230
|
+
def sign_csr(self, csr):
|
|
231
|
+
key_size = self.key.curve.key_size
|
|
232
|
+
if key_size == 256:
|
|
233
|
+
algorithm = hashes.SHA256()
|
|
234
|
+
elif key_size == 384:
|
|
235
|
+
algorithm = hashes.SHA384()
|
|
236
|
+
elif key_size == 521:
|
|
237
|
+
algorithm = hashes.SHA512()
|
|
238
|
+
else:
|
|
239
|
+
raise ValueError(f"Unsupported curve with key size {key_size}")
|
|
240
|
+
return csr.sign(self.key, algorithm)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from typing import Optional, List, Union
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from cryptography import x509
|
|
4
|
+
from cryptography.x509.oid import NameOID
|
|
5
|
+
from certapi.crypto_classes import Key, RSAKey, ECDSAKey, Ed25519Key # Assuming Key classes are in crypto_keys module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CertificateIssuer:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
key: Key,
|
|
12
|
+
*,
|
|
13
|
+
country: Optional[str] = "NP",
|
|
14
|
+
state: Optional[str] = "Kathmandu",
|
|
15
|
+
locality: Optional[str] = "Buddhanagar",
|
|
16
|
+
organization: Optional[str] = "Sireto Technology",
|
|
17
|
+
common_name: Optional[str] = "sireto.io",
|
|
18
|
+
):
|
|
19
|
+
"""Initialize the CertificateIssuer with a Key object."""
|
|
20
|
+
self.root_key: Key = key
|
|
21
|
+
self.issuer_fields = {
|
|
22
|
+
"country": country,
|
|
23
|
+
"state": state,
|
|
24
|
+
"locality": locality,
|
|
25
|
+
"organization": organization,
|
|
26
|
+
"common_name": common_name,
|
|
27
|
+
}
|
|
28
|
+
self.issuer = self._build_name(self.issuer_fields)
|
|
29
|
+
|
|
30
|
+
def _build_name(self, fields: dict, include_user_id=False, domain=None) -> x509.Name:
|
|
31
|
+
"""Build an X509 Name object from field dictionary."""
|
|
32
|
+
name_attrs = []
|
|
33
|
+
field_map = {
|
|
34
|
+
"country": NameOID.COUNTRY_NAME,
|
|
35
|
+
"state": NameOID.STATE_OR_PROVINCE_NAME,
|
|
36
|
+
"locality": NameOID.LOCALITY_NAME,
|
|
37
|
+
"organization": NameOID.ORGANIZATION_NAME,
|
|
38
|
+
"common_name": NameOID.COMMON_NAME,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for key, oid in field_map.items():
|
|
42
|
+
value = fields.get(key)
|
|
43
|
+
if value:
|
|
44
|
+
name_attrs.append(x509.NameAttribute(oid, value))
|
|
45
|
+
|
|
46
|
+
if include_user_id:
|
|
47
|
+
user_id = fields.get("user_id") or domain
|
|
48
|
+
if user_id:
|
|
49
|
+
name_attrs.append(x509.NameAttribute(NameOID.USER_ID, user_id))
|
|
50
|
+
|
|
51
|
+
return x509.Name(name_attrs)
|
|
52
|
+
|
|
53
|
+
def get_ca_cert(self) -> x509.Certificate:
|
|
54
|
+
"""Generate a self-signed CA certificate."""
|
|
55
|
+
now = datetime.now(timezone.utc)
|
|
56
|
+
builder = (
|
|
57
|
+
x509.CertificateBuilder()
|
|
58
|
+
.subject_name(self.issuer)
|
|
59
|
+
.issuer_name(self.issuer)
|
|
60
|
+
.public_key(self.root_key.key.public_key())
|
|
61
|
+
.serial_number(x509.random_serial_number())
|
|
62
|
+
.not_valid_before(now)
|
|
63
|
+
.not_valid_after(now + timedelta(days=365))
|
|
64
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
65
|
+
)
|
|
66
|
+
return self.root_key.sign_csr(builder)
|
|
67
|
+
|
|
68
|
+
def sign_csr(
|
|
69
|
+
self,
|
|
70
|
+
csr: x509.CertificateSigningRequest,
|
|
71
|
+
expiry_days: int = 90,
|
|
72
|
+
) -> x509.Certificate:
|
|
73
|
+
"""Sign a CSR and return a certificate signed by this CA."""
|
|
74
|
+
now = datetime.now(timezone.utc)
|
|
75
|
+
builder = (
|
|
76
|
+
x509.CertificateBuilder()
|
|
77
|
+
.subject_name(csr.subject)
|
|
78
|
+
.issuer_name(self.issuer)
|
|
79
|
+
.public_key(csr.public_key())
|
|
80
|
+
.serial_number(x509.random_serial_number())
|
|
81
|
+
.not_valid_before(now)
|
|
82
|
+
.not_valid_after(now + timedelta(days=expiry_days))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for ext in csr.extensions:
|
|
86
|
+
builder = builder.add_extension(ext.value, ext.critical)
|
|
87
|
+
|
|
88
|
+
return self.root_key.sign_csr(builder)
|
|
89
|
+
|
|
90
|
+
def create_key_and_cert(
|
|
91
|
+
self,
|
|
92
|
+
domain: str,
|
|
93
|
+
alt_names: List[str] = (),
|
|
94
|
+
key_type: str = "rsa",
|
|
95
|
+
expiry_days: int = 90,
|
|
96
|
+
country: Optional[str] = None,
|
|
97
|
+
state: Optional[str] = None,
|
|
98
|
+
locality: Optional[str] = None,
|
|
99
|
+
organization: Optional[str] = None,
|
|
100
|
+
user_id: Optional[str] = None,
|
|
101
|
+
) -> tuple:
|
|
102
|
+
"""Create a new certificate with a generated key."""
|
|
103
|
+
# Generate new key based on key_type
|
|
104
|
+
if key_type == "rsa":
|
|
105
|
+
new_key = RSAKey.generate()
|
|
106
|
+
elif key_type == "ecdsa":
|
|
107
|
+
new_key = ECDSAKey.generate()
|
|
108
|
+
elif key_type == "ed25519":
|
|
109
|
+
new_key = Ed25519Key.generate()
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError("Unsupported key type. Use 'rsa' or 'ecdsa'")
|
|
112
|
+
|
|
113
|
+
# Create CSR using the new key
|
|
114
|
+
csr = new_key.create_csr(
|
|
115
|
+
domain=domain,
|
|
116
|
+
alt_names=alt_names,
|
|
117
|
+
country=country or self.issuer_fields.get("country"),
|
|
118
|
+
state=state or self.issuer_fields.get("state"),
|
|
119
|
+
locality=locality or self.issuer_fields.get("locality"),
|
|
120
|
+
organization=organization or self.issuer_fields.get("organization"),
|
|
121
|
+
user_id=user_id or domain,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Sign the CSR to get the certificate
|
|
125
|
+
cert = self.sign_csr(csr, expiry_days=expiry_days)
|
|
126
|
+
|
|
127
|
+
return new_key, cert
|
|
@@ -162,7 +162,7 @@ class FilesystemKeyStore(KeyStore):
|
|
|
162
162
|
return name # Dummy ID since filesystem does not use numeric IDs
|
|
163
163
|
|
|
164
164
|
def gen_key(self, name: str = None, size: int = 4096) -> RSAPrivateKey:
|
|
165
|
-
key =
|
|
165
|
+
key = gen_key_secp256r1()
|
|
166
166
|
self.save_key(key, name)
|
|
167
167
|
return key
|
|
168
168
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python Package for managing keys, request SSL certificates from ACME.
|
|
5
5
|
Home-page: https://github.com/mesudip/certapi
|
|
6
6
|
Author: Sudip Bhattarai
|
|
@@ -10,8 +10,6 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Requires-Python: >=3.6
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
-
Requires-Dist: cryptography
|
|
14
|
-
Requires-Dist: requests
|
|
15
13
|
|
|
16
14
|
# CertApi
|
|
17
15
|
|
|
@@ -6,6 +6,8 @@ src/certapi/Acme.py
|
|
|
6
6
|
src/certapi/__init__.py
|
|
7
7
|
src/certapi/certauthority.py
|
|
8
8
|
src/certapi/challenge.py
|
|
9
|
+
src/certapi/cloudflare_challenge_store.py
|
|
10
|
+
src/certapi/cloudflare_client.py
|
|
9
11
|
src/certapi/crypto.py
|
|
10
12
|
src/certapi/crypto_classes.py
|
|
11
13
|
src/certapi/custom_certauthority.py
|
|
@@ -15,5 +17,4 @@ src/certapi.egg-info/PKG-INFO
|
|
|
15
17
|
src/certapi.egg-info/SOURCES.txt
|
|
16
18
|
src/certapi.egg-info/dependency_links.txt
|
|
17
19
|
src/certapi.egg-info/requires.txt
|
|
18
|
-
src/certapi.egg-info/top_level.txt
|
|
19
|
-
tests/test_custom_certauthority.py
|
|
20
|
+
src/certapi.egg-info/top_level.txt
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
from abc import ABC, abstractmethod
|
|
2
|
-
from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec
|
|
3
|
-
from cryptography.hazmat.primitives import serialization, hashes, hmac, padding
|
|
4
|
-
from typing import Union
|
|
5
|
-
|
|
6
|
-
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
7
|
-
|
|
8
|
-
from .crypto import key_to_der, key_to_pem
|
|
9
|
-
from .util import b64_string
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class Key(ABC):
|
|
13
|
-
@abstractmethod
|
|
14
|
-
def jwk(self):
|
|
15
|
-
pass
|
|
16
|
-
|
|
17
|
-
@abstractmethod
|
|
18
|
-
def sign(self, message):
|
|
19
|
-
pass
|
|
20
|
-
|
|
21
|
-
@abstractmethod
|
|
22
|
-
def sign_csr(self, csr):
|
|
23
|
-
pass
|
|
24
|
-
|
|
25
|
-
@staticmethod
|
|
26
|
-
def from_der(der_bytes):
|
|
27
|
-
key = serialization.load_der_private_key(der_bytes, password=None)
|
|
28
|
-
if isinstance(key, rsa.RSAPrivateKey):
|
|
29
|
-
return RSAKey(key)
|
|
30
|
-
elif isinstance(key, ec.EllipticCurvePrivateKey):
|
|
31
|
-
return ECDSAKey(key)
|
|
32
|
-
elif isinstance(key, Ed25519PrivateKey):
|
|
33
|
-
return Ed25519Key(key)
|
|
34
|
-
else:
|
|
35
|
-
raise ValueError("Unsupported key type")
|
|
36
|
-
|
|
37
|
-
@staticmethod
|
|
38
|
-
def from_pem(der_bytes):
|
|
39
|
-
key = serialization.load_pem_private_key(der_bytes, password=None)
|
|
40
|
-
if isinstance(key, rsa.RSAPrivateKey):
|
|
41
|
-
return RSAKey(key)
|
|
42
|
-
elif isinstance(key, ec.EllipticCurvePrivateKey):
|
|
43
|
-
return ECDSAKey(key)
|
|
44
|
-
elif isinstance(key, Ed25519PrivateKey):
|
|
45
|
-
return Ed25519Key(key)
|
|
46
|
-
else:
|
|
47
|
-
raise ValueError("Unsupported key type")
|
|
48
|
-
|
|
49
|
-
def to_der(self) -> bytes:
|
|
50
|
-
return key_to_der(self.key)
|
|
51
|
-
|
|
52
|
-
def to_pem(self) -> bytes:
|
|
53
|
-
return key_to_pem(self.key)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class RSAKey(Key):
|
|
57
|
-
def __init__(self, key: rsa.RSAPrivateKey, hasher=hashes.SHA256()):
|
|
58
|
-
self.key = key
|
|
59
|
-
self.hasher = hasher
|
|
60
|
-
|
|
61
|
-
def jwk(self):
|
|
62
|
-
public = self.key.public_key().public_numbers()
|
|
63
|
-
return {
|
|
64
|
-
"e": b64_string((public.e).to_bytes((public.e.bit_length() + 7) // 8, "big")),
|
|
65
|
-
"kty": "RSA",
|
|
66
|
-
"n": b64_string((public.n).to_bytes((public.n.bit_length() + 7) // 8, "big")),
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
def sign(self, message):
|
|
70
|
-
return self.key.sign(message, padding.PKCS1v15(), self.hasher)
|
|
71
|
-
|
|
72
|
-
def sign_csr(self, csr):
|
|
73
|
-
return csr.sign(self.key, self.hasher)
|
|
74
|
-
|
|
75
|
-
def algorithm_name(self):
|
|
76
|
-
return "RS" + str(self.hasher.digest_size * 8)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class Ed25519Key(Key):
|
|
80
|
-
|
|
81
|
-
def __init__(self, key: ed25519.Ed25519PrivateKey):
|
|
82
|
-
self.key = key
|
|
83
|
-
self.keyid = "e"
|
|
84
|
-
public = self.key.public_key().public_bytes(
|
|
85
|
-
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
|
|
86
|
-
)
|
|
87
|
-
self.jwk = {
|
|
88
|
-
"crv": "Ed25519",
|
|
89
|
-
"kty": "OKP",
|
|
90
|
-
"x": b64_string(public),
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
def jwk(self):
|
|
94
|
-
return self.jwk
|
|
95
|
-
|
|
96
|
-
def sign(self, message):
|
|
97
|
-
return self.key.sign(message)
|
|
98
|
-
|
|
99
|
-
def sign_csr(self, csr):
|
|
100
|
-
return csr.sign(self.key, hashes.SHA256())
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class ECDSAKey(Key):
|
|
104
|
-
|
|
105
|
-
def __init__(self, key: ec.EllipticCurvePrivateKey):
|
|
106
|
-
self.key = key
|
|
107
|
-
public_key = self.key.public_key()
|
|
108
|
-
public_numbers = public_key.public_numbers()
|
|
109
|
-
self.jwk = {
|
|
110
|
-
"kty": "EC",
|
|
111
|
-
"crv": self.key.curve.name,
|
|
112
|
-
"x": b64_string(public_numbers.x.to_bytes((public_numbers.x.bit_length() + 7) // 8, "big")),
|
|
113
|
-
"y": b64_string(public_numbers.y.to_bytes((public_numbers.y.bit_length() + 7) // 8, "big")),
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
def jwk(self):
|
|
117
|
-
return self.jwk
|
|
118
|
-
|
|
119
|
-
def algorithm_name(self):
|
|
120
|
-
return self.key.curve.name
|
|
121
|
-
|
|
122
|
-
def sign(self, message):
|
|
123
|
-
key_size = self.key.curve.key_size
|
|
124
|
-
if key_size == 256:
|
|
125
|
-
algorithm = hashes.SHA256()
|
|
126
|
-
elif key_size == 384:
|
|
127
|
-
algorithm = hashes.SHA384()
|
|
128
|
-
elif key_size == 521:
|
|
129
|
-
algorithm = hashes.SHA512()
|
|
130
|
-
else:
|
|
131
|
-
raise ValueError(f"Unsupported curve with key size {key_size}")
|
|
132
|
-
return self.key.sign(message, ec.ECDSA(algorithm))
|
|
133
|
-
|
|
134
|
-
def sign_csr(self, csr):
|
|
135
|
-
return csr.sign(self.key, hashes.SHA256())
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import time
|
|
3
|
-
from datetime import datetime, timedelta
|
|
4
|
-
from typing import Union, Callable, List
|
|
5
|
-
|
|
6
|
-
import requests
|
|
7
|
-
from cryptography import x509
|
|
8
|
-
from cryptography.hazmat._oid import NameOID
|
|
9
|
-
from cryptography.hazmat.primitives import hashes, serialization
|
|
10
|
-
from cryptography.hazmat.primitives.asymmetric.dsa import DSAPrivateKey
|
|
11
|
-
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
12
|
-
from cryptography.x509 import Certificate
|
|
13
|
-
from requests import Response
|
|
14
|
-
|
|
15
|
-
from . import Acme
|
|
16
|
-
from . import db
|
|
17
|
-
from . import crypto
|
|
18
|
-
from . import challenge
|
|
19
|
-
from .crypto import csr_to_pem, create_csr, gen_key_secp256r1
|
|
20
|
-
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding, ed25519, dsa
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# pathlib.Path.mkdir("/var/www/html/.acme/well-known")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class CustomCertAuthority:
|
|
27
|
-
def __init__(self, key: Union[RSAPrivateKey, DSAPrivateKey, ec.EllipticCurvePrivateKey]):
|
|
28
|
-
self.root_key = key
|
|
29
|
-
self.issuer = x509.Name(
|
|
30
|
-
[
|
|
31
|
-
x509.NameAttribute(NameOID.COUNTRY_NAME, "NP"),
|
|
32
|
-
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Kathmandu"),
|
|
33
|
-
x509.NameAttribute(NameOID.LOCALITY_NAME, "Buddhanagar"),
|
|
34
|
-
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Sireto Technology"),
|
|
35
|
-
x509.NameAttribute(NameOID.COMMON_NAME, "sireto.io"),
|
|
36
|
-
]
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
def get_ca_cert(self):
|
|
40
|
-
|
|
41
|
-
# Assuming self.root_key is the CA private key
|
|
42
|
-
cert = (
|
|
43
|
-
x509.CertificateBuilder()
|
|
44
|
-
.subject_name(self.issuer)
|
|
45
|
-
.issuer_name(self.issuer)
|
|
46
|
-
.public_key(self.root_key.public_key())
|
|
47
|
-
.serial_number(x509.random_serial_number())
|
|
48
|
-
.not_valid_before(datetime.utcnow())
|
|
49
|
-
.not_valid_after(datetime.utcnow() + timedelta(days=365))
|
|
50
|
-
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
51
|
-
.sign(self.root_key, hashes.SHA256())
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
return cert
|
|
55
|
-
|
|
56
|
-
# Assuming CustomCertAuthority is your class
|
|
57
|
-
|
|
58
|
-
def create_cert(self, domain: str, alt_names: List[str] = (), key_type: str = "rsa", expiry_days=90):
|
|
59
|
-
if key_type == "rsa":
|
|
60
|
-
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
61
|
-
elif key_type == "dsa":
|
|
62
|
-
key = dsa.generate_private_key(key_size=2048)
|
|
63
|
-
elif key_type == "ecdsa":
|
|
64
|
-
key = gen_key_secp256r1()
|
|
65
|
-
else:
|
|
66
|
-
raise ValueError("Unsupported key type")
|
|
67
|
-
|
|
68
|
-
subject = x509.Name(
|
|
69
|
-
[
|
|
70
|
-
x509.NameAttribute(NameOID.COUNTRY_NAME, "NP"),
|
|
71
|
-
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Kathmandu"),
|
|
72
|
-
x509.NameAttribute(NameOID.LOCALITY_NAME, "Buddhanagar"),
|
|
73
|
-
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Sireto Technology"),
|
|
74
|
-
x509.NameAttribute(NameOID.USER_ID, domain),
|
|
75
|
-
]
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
now = datetime.utcnow()
|
|
79
|
-
cert_builder = (
|
|
80
|
-
x509.CertificateBuilder()
|
|
81
|
-
.subject_name(subject)
|
|
82
|
-
.issuer_name(self.issuer)
|
|
83
|
-
.public_key(key.public_key())
|
|
84
|
-
.serial_number(x509.random_serial_number())
|
|
85
|
-
.not_valid_before(now)
|
|
86
|
-
.not_valid_after(now + timedelta(days=expiry_days))
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
for alt_name in alt_names:
|
|
90
|
-
cert_builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(alt_name)]), critical=False)
|
|
91
|
-
|
|
92
|
-
cert = cert_builder.sign(self.root_key, hashes.SHA256())
|
|
93
|
-
|
|
94
|
-
return (key, cert)
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from cryptography.hazmat.primitives import serialization
|
|
3
|
-
from certapi.crypto import gen_key_rsa, key_to_pem
|
|
4
|
-
from certapi.custom_certauthority import CustomCertAuthority
|
|
5
|
-
import os
|
|
6
|
-
from contextlib import contextmanager
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@pytest.fixture
|
|
10
|
-
def rsa_key():
|
|
11
|
-
return gen_key_rsa()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
test_new_file_dir = "./build/tests/intemediate-files"
|
|
15
|
-
os.makedirs(test_new_file_dir, exist_ok=True)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@contextmanager
|
|
19
|
-
def tmp_test_file(filename):
|
|
20
|
-
with open(os.path.join(test_new_file_dir, filename), "wb") as f:
|
|
21
|
-
yield f
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def write_tmp_file(filename, content):
|
|
25
|
-
with tmp_test_file(filename) as f:
|
|
26
|
-
f.write(content)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_self_sign_certificat(rsa_key):
|
|
30
|
-
certauthority = CustomCertAuthority(rsa_key)
|
|
31
|
-
(key, cert) = certauthority.create_cert("sudip.sireto.io", key_type="rsa")
|
|
32
|
-
write_tmp_file("test_self_sign_private.key", key_to_pem(key))
|
|
33
|
-
write_tmp_file("test_self_sign_certificate.crt", cert.public_bytes(serialization.Encoding.PEM))
|
|
34
|
-
|
|
35
|
-
with tmp_test_file("test_self_sign_certificate.p12") as f:
|
|
36
|
-
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
37
|
-
f.write(key_to_pem(key))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_get_ca(rsa_key):
|
|
41
|
-
certauthority = CustomCertAuthority(rsa_key)
|
|
42
|
-
cert = certauthority.get_ca_cert()
|
|
43
|
-
write_tmp_file("test_self_sign_ca.crt", cert.public_bytes(serialization.Encoding.PEM))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|