certapi 0.4.0__tar.gz → 0.4.1__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.
Files changed (56) hide show
  1. {certapi-0.4.0 → certapi-0.4.1}/PKG-INFO +2 -2
  2. {certapi-0.4.0 → certapi-0.4.1}/README.md +1 -1
  3. {certapi-0.4.0 → certapi-0.4.1}/setup.py +1 -1
  4. certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +59 -0
  5. certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py +138 -0
  6. certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +57 -0
  7. certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py +85 -0
  8. certapi-0.4.1/src/certapi/client/__init__.py +0 -0
  9. certapi-0.4.1/src/certapi/http/__init__.py +0 -0
  10. certapi-0.4.1/src/certapi/http/client.py +0 -0
  11. certapi-0.4.1/src/certapi/http/types.py +68 -0
  12. certapi-0.4.1/src/certapi/manager/__init__.py +0 -0
  13. certapi-0.4.1/src/certapi/manager/acme_cert_manager.py +155 -0
  14. certapi-0.4.1/src/certapi/server/__init__.py +0 -0
  15. {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/PKG-INFO +2 -2
  16. {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/SOURCES.txt +11 -0
  17. {certapi-0.4.0 → certapi-0.4.1}/MANIFEST.in +0 -0
  18. {certapi-0.4.0 → certapi-0.4.1}/pyproject.toml +0 -0
  19. {certapi-0.4.0 → certapi-0.4.1}/setup.cfg +0 -0
  20. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/__init__.py +0 -0
  21. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Acme.py +0 -0
  22. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/AcmeError.py +0 -0
  23. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Challenge.py +0 -0
  24. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Order.py +0 -0
  25. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/__init__.py +0 -0
  26. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/http.py +0 -0
  27. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/ChallengeStore.py +0 -0
  28. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/FileSystemChallengeStore.py +0 -0
  29. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/FilesystemChallengeStore.py +0 -0
  30. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/InmemoryChallengeStore.py +0 -0
  31. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/__init__.py +0 -0
  32. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/dns/__init__.py +0 -0
  33. {certapi-0.4.0/src/certapi/client → certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare}/__init__.py +0 -0
  34. {certapi-0.4.0/src/certapi/server → certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean}/__init__.py +0 -0
  35. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/client/cert_manager_client.py +0 -0
  36. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/__init__.py +0 -0
  37. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/crypto.py +0 -0
  38. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/crypto_classes.py +0 -0
  39. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  40. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  41. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/__init__.py +0 -0
  42. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
  43. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/FileSystemKeystore.py +0 -0
  44. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/KeyStore.py +0 -0
  45. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  46. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  47. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  48. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/__init__.py +0 -0
  49. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/api.py +0 -0
  50. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/cert_api.py +0 -0
  51. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/key_api.py +0 -0
  52. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/util.py +0 -0
  53. {certapi-0.4.0 → certapi-0.4.1}/src/certapi/utils.py +0 -0
  54. {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/dependency_links.txt +0 -0
  55. {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/requires.txt +0 -0
  56. {certapi-0.4.0 → certapi-0.4.1}/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.4.0
3
+ Version: 0.4.1
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
@@ -33,7 +33,7 @@ import json
33
33
  from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
34
34
 
35
35
  key_store = FilesystemKeyStore("data")
36
- challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
36
+ challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
37
37
 
38
38
  certAuthority = CertAuthority(challenge_store, key_store)
39
39
  certAuthority.setup()
@@ -20,7 +20,7 @@ import json
20
20
  from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
21
21
 
22
22
  key_store = FilesystemKeyStore("data")
23
- challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
23
+ challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
24
24
 
25
25
  certAuthority = CertAuthority(challenge_store, key_store)
26
26
  certAuthority.setup()
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.4.0",
5
+ version="0.4.1",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -0,0 +1,59 @@
1
+ import os
2
+ import time
3
+ from collections.abc import MutableMapping
4
+ from ...ChallengeStore import ChallengeStore
5
+ from .cloudflare_client import Cloudflare
6
+
7
+
8
+ class CloudflareChallengeStore(ChallengeStore):
9
+ def __init__(self,api_key:str=None):
10
+ self.cloudflare = Cloudflare(api_key)
11
+ self.challenges_map = {}
12
+
13
+ def supports_domain(self, domain: str) -> bool:
14
+ """
15
+ Checks if the Cloudflare account has access to the given domain (or its base domain)
16
+ as a registered zone.
17
+ """
18
+ try:
19
+ self.cloudflare.determine_registered_domain(domain)
20
+ return True
21
+ except Exception:
22
+ return False
23
+
24
+ def save_challenge(self, key: str, value: str, domain=None):
25
+ # key example: _acme-challenge.sub.example.com
26
+ # value example: ACME_CHALLENGE_TOKEN
27
+ base_domain = self.cloudflare.determine_registered_domain(domain)
28
+
29
+ record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
30
+ self.challenges_map[key] = record_id
31
+ print(f"CloudflareChallengeStore: Saved challenge for {key} with record ID {record_id}")
32
+ time.sleep(10) # wait for dns propagation. it may not be immediate
33
+
34
+ def get_challenge(self, key: str, domain: str) -> str:
35
+ base_domain = self.cloudflare.determine_registered_domain(domain)
36
+ records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
37
+ for record in records:
38
+ if record["name"] == key:
39
+ return record["content"]
40
+ return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
41
+
42
+ def delete_challenge(self, key: str, domain: str):
43
+ if key not in self.challenges_map:
44
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
45
+
46
+ record_id = self.challenges_map[key]
47
+ base_domain = self.cloudflare.determine_registered_domain(domain)
48
+ self.cloudflare.delete_record(record=record_id, domain=base_domain)
49
+ del self.challenges_map[key]
50
+ print(f"CloudflareChallengeStore: Deleted challenge for {key} with record ID {record_id}")
51
+
52
+ def __iter__(self):
53
+ # This is tricky as we can't easily iterate all challenges across all domains
54
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
55
+ return iter(self.challenges_map)
56
+
57
+ def __len__(self):
58
+ # Similar to __iter__, this will count challenges managed by this store instance.
59
+ return len(self.challenges_map)
@@ -0,0 +1,138 @@
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,api_key:str):
11
+ self.token = api_key
12
+ self.api = "https://api.cloudflare.com/client/v4"
13
+ if not self.token:
14
+ self.token = getenv("CLOUDFLARE_API_KEY")
15
+ if not self.token:
16
+ raise Exception("CLOUDFLARE_API_KEY not found in environment")
17
+
18
+ self._zones_cache = None
19
+ self._zones_cache_time = 0 # Unix timestamp of last cache update
20
+
21
+ def _cloudflare_headers(self):
22
+ return {"Content-Type": "application/json", "Authorization": "Bearer " + self.token}
23
+
24
+ def _get_zones(self):
25
+ """Fetch and cache Cloudflare zones"""
26
+ # Cache for 1 day (86400 seconds)
27
+ if self._zones_cache and (time.time() - self._zones_cache_time) < 86400:
28
+ return self._zones_cache
29
+
30
+ request_headers = self._cloudflare_headers()
31
+ api_url = "{0}/zones?per_page=50".format(self.api)
32
+ response = urlopen(Request(api_url, headers=request_headers))
33
+ if response.getcode() != 200:
34
+ raise Exception(json.loads(response.read().decode("utf8")))
35
+
36
+ zones = json.loads(response.read().decode("utf8"))["result"]
37
+ self._zones_cache = zones
38
+ self._zones_cache_time = time.time()
39
+ return zones
40
+
41
+ def _get_zone_id(self, domain):
42
+ """Determine Cloudflare Zone ID for a given domain"""
43
+ zones = self._get_zones()
44
+ for zone in zones:
45
+ if zone["name"] == domain:
46
+ return zone["id"]
47
+ raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
48
+
49
+ def determine_registered_domain(self, domain: str) -> str:
50
+ """
51
+ Determine the registered domain in Cloudflare for a given (sub)domain.
52
+ This method iterates through parts of the domain to find a matching Cloudflare zone.
53
+ """
54
+ parts = domain.split(".")
55
+ err = None
56
+ for i in range(len(parts)):
57
+ potential_domain = ".".join(parts[i:])
58
+ try:
59
+ self._get_zone_id(potential_domain)
60
+ return potential_domain
61
+ except Exception as e:
62
+ err = e
63
+ continue
64
+ if err:
65
+ raise err
66
+ else:
67
+ raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
68
+
69
+ def list_txt_records(self, domain: str, name_filter: str = None) -> list:
70
+ """
71
+ Lists TXT records for a given domain, optionally filtered by name.
72
+ Returns a list of dictionaries, each representing a TXT record.
73
+ """
74
+ registered_domain = self.determine_registered_domain(domain)
75
+ zone_id = self._get_zone_id(registered_domain)
76
+ api_url = f"{self.api}/zones/{zone_id}/dns_records?type=TXT"
77
+ if name_filter:
78
+ api_url += f"&name={name_filter}"
79
+
80
+ request_headers = self._cloudflare_headers()
81
+ response = urlopen(Request(api_url, headers=request_headers))
82
+
83
+ if response.getcode() != 200:
84
+ raise Exception(json.loads(response.read().decode("utf8")))
85
+
86
+ result = json.loads(response.read().decode("utf8"))
87
+ if not result.get("success"):
88
+ raise Exception(result.get("errors", "Unknown error listing TXT records"))
89
+
90
+ return result["result"]
91
+
92
+ def create_record(self, name, data, domain):
93
+ """
94
+ Create DNS record
95
+ Params:
96
+ name, string, record name (e.g., _acme-challenge.example.com)
97
+ data, string, record data (e.g., ACME challenge token)
98
+ domain, string, dns domain (e.g., example.com) - This will be used to determine the registered zone.
99
+ Return:
100
+ record_id, string, created record id
101
+ """
102
+ registered_domain = self.determine_registered_domain(domain)
103
+ zone_id = self._get_zone_id(registered_domain)
104
+ api_url = "{0}/zones/{1}/dns_records".format(self.api, zone_id)
105
+ request_headers = self._cloudflare_headers()
106
+ request_data = {
107
+ "type": "TXT",
108
+ "name": name,
109
+ "content": data,
110
+ "ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
111
+ "proxied": False,
112
+ }
113
+ response = urlopen(Request(api_url, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
114
+
115
+ if response.getcode() != 200:
116
+ raise Exception(json.loads(response.read().decode("utf8")))
117
+ result = response.read().decode("utf8")
118
+ print("Cloudflare create record", name, result)
119
+ return json.loads(result)["result"]["id"]
120
+
121
+ def delete_record(self, record, domain):
122
+ """
123
+ Delete DNS record
124
+ Params:
125
+ record, string, record id number
126
+ domain, string, dns domain - This will be used to determine the registered zone.
127
+ """
128
+ registered_domain = self.determine_registered_domain(domain)
129
+ zone_id = self._get_zone_id(registered_domain)
130
+ api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api, zone_id, record)
131
+ request_headers = self._cloudflare_headers()
132
+ request = Request(api_url, headers=request_headers)
133
+ request.get_method = lambda: "DELETE"
134
+ response = urlopen(request)
135
+ result = response.read().decode("utf8")
136
+ print(f"Delete dns record [{response.getcode()}]", result)
137
+ if response.getcode() != 200:
138
+ raise Exception(json.loads(response.read().decode("utf8")))
@@ -0,0 +1,57 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from ...ChallengeStore import ChallengeStore
4
+ from .digitalocean_client import DigitalOcean
5
+
6
+
7
+ class DigitalOceanChallengeStore(ChallengeStore):
8
+ def __init__(self,api_key:str=None):
9
+ self.digitalocean = DigitalOcean(api_key)
10
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
11
+
12
+ def supports_domain(self, domain: str) -> bool:
13
+ """
14
+ Checks if the DigitalOcean account has access to the given domain (or its base domain)
15
+ as a registered zone.
16
+ """
17
+ try:
18
+ self.digitalocean.determine_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.digitalocean.determine_domain(domain)
27
+
28
+ record_id = self.digitalocean.create_record(name=key, data=value, domain=base_domain)
29
+ self.challenges_map[key] = record_id
30
+ print(f"DigitalOceanChallengeStore: Saved challenge for {key} with record ID {record_id}")
31
+
32
+ def get_challenge(self, key: str, domain: str) -> str:
33
+ base_domain = self.digitalocean.determine_domain(domain)
34
+ records = self.digitalocean.list_records(base_domain, name_filter=key)
35
+ for record in records:
36
+ if record["name"] == key:
37
+ return record["data"] # DigitalOcean API returns 'data' for TXT 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.digitalocean.determine_domain(domain)
46
+ self.digitalocean.delete_record(record=record_id, domain=base_domain)
47
+ del self.challenges_map[key]
48
+ print(f"DigitalOceanChallengeStore: 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,85 @@
1
+ import json
2
+ from os import getenv
3
+
4
+ from urllib.request import urlopen, Request # Python 3
5
+
6
+
7
+ class DigitalOcean(object):
8
+ def __init__(self, api_key:str=None):
9
+ self.token = api_key
10
+ self.api = "https://api.digitalocean.com/v2/domains"
11
+ if not self.token:
12
+ self.token = getenv("DIGITALOCEAN_API_KEY")
13
+ if not self.token:
14
+ raise Exception("DIGITALOCEAN_API_KEY not found in environment")
15
+
16
+ def determine_domain(self, domain):
17
+ """Determine registered domain in API"""
18
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
19
+ response = urlopen(Request(self.api, headers=request_headers))
20
+ if response.getcode() != 200:
21
+ raise Exception(json.loads(response.read().decode("utf8")))
22
+ domains = json.loads(response.read().decode("utf8"))["domains"]
23
+ for d in domains:
24
+ if d["name"] in domain:
25
+ return d["name"]
26
+
27
+ def create_record(self, name, data, domain):
28
+ """
29
+ Create DNS record
30
+ Params:
31
+ name, string, record name
32
+ data, string, record data
33
+ domain, string, dns domain
34
+ Return:
35
+ record_id, int, created record id
36
+ """
37
+ registered_domain = self.determine_domain(domain)
38
+ api = self.api + "/" + registered_domain + "/records"
39
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
40
+ request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data}
41
+ response = urlopen(Request(api, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
42
+ if response.getcode() != 201:
43
+ raise Exception(json.loads(response.read().decode("utf8")))
44
+ return json.loads(response.read().decode("utf8"))["domain_record"]["id"]
45
+
46
+ def delete_record(self, record, domain):
47
+ """
48
+ Delete DNS record
49
+ Params:
50
+ record, int, record id number
51
+ domain, string, dns domain
52
+ """
53
+ registered_domain = self.determine_domain(domain)
54
+ api = self.api + "/" + registered_domain + "/records/" + str(record)
55
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
56
+ request = Request(api, data=json.dumps({}).encode("utf8"), headers=request_headers)
57
+ request.get_method = lambda: "DELETE"
58
+ response = urlopen(request)
59
+ if response.getcode() != 204:
60
+ raise Exception(json.loads(response.read().decode("utf8")))
61
+
62
+ def list_records(self, domain, name_filter=None):
63
+ """
64
+ List DNS records for a domain, optionally filtered by name.
65
+ Params:
66
+ domain, string, dns domain
67
+ name_filter, string, optional filter for record name
68
+ Return:
69
+ records, list of dicts, matching DNS records
70
+ """
71
+ registered_domain = self.determine_domain(domain)
72
+ api = self.api + "/" + registered_domain + "/records"
73
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
74
+ response = urlopen(Request(api, headers=request_headers))
75
+ if response.getcode() != 200:
76
+ raise Exception(json.loads(response.read().decode("utf8")))
77
+
78
+ all_records = json.loads(response.read().decode("utf8"))["domain_records"]
79
+
80
+ if name_filter:
81
+ filtered_records = [r for r in all_records if r["name"] == name_filter and r["type"] == "TXT"]
82
+ else:
83
+ filtered_records = [r for r in all_records if r["type"] == "TXT"]
84
+
85
+ return filtered_records
File without changes
File without changes
File without changes
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+ from typing import List, Dict, Any, Union, Optional
3
+ from cryptography.x509 import Certificate
4
+ from ..crypto.crypto import cert_to_pem, certs_to_pem
5
+ from ..crypto.crypto_classes import Key
6
+
7
+ class IssuedCert:
8
+ def __init__(self, *, key: Union[str, Key] = None, cert: Union[str, Certificate, List[Certificate]] = None, domains: List[str] = None):
9
+ if isinstance(key, Key):
10
+ key = key.to_pem().decode("utf-8")
11
+ elif isinstance(key, bytes):
12
+ key = key.decode("utf-8")
13
+
14
+ if isinstance(cert, list):
15
+ cert = certs_to_pem(cert).decode("utf-8")
16
+ elif isinstance(cert, Certificate):
17
+ cert = cert_to_pem(cert).decode("utf-8")
18
+ elif isinstance(cert, bytes):
19
+ cert = cert.decode("utf-8")
20
+
21
+ self.privateKey = key
22
+ self.certificate = cert
23
+ self.domains = domains
24
+
25
+ @staticmethod
26
+ def from_json(data: Dict[str, Any]) -> "IssuedCert":
27
+ return IssuedCert(
28
+ key=data.get('privateKey'),
29
+ cert=data.get('certificate'),
30
+ domains=data.get('domains', []),
31
+ )
32
+
33
+ def __repr__(self):
34
+ return f"IssuedCert(domains={self.domains})"
35
+
36
+ def __str__(self):
37
+ return f"(hosts: {self.domains}, certificate:{self.certificate})"
38
+
39
+ def to_json(self):
40
+ return {"privateKey": self.privateKey, "certificate": self.certificate, "domains": self.domains}
41
+
42
+
43
+ class CertificateResponse:
44
+ def __init__(self, *, existing: List[IssuedCert] = None, issued: List[IssuedCert] = None):
45
+ self.existing: List[IssuedCert] = existing or []
46
+ self.issued: List[IssuedCert] = issued or []
47
+
48
+ @staticmethod
49
+ def from_json(data: Dict[str, Any]) -> "CertificateResponse":
50
+ return CertificateResponse(
51
+ existing=[IssuedCert.from_json(cert_data) for cert_data in data.get('existing', [])],
52
+ issued=[IssuedCert.from_json(cert_data) for cert_data in data.get('issued', [])]
53
+ )
54
+
55
+ def __repr__(self):
56
+ return f"CertificateResponse(existing={self.existing}, issued={self.issued})"
57
+
58
+ def __str__(self):
59
+ if self.issued:
60
+ return f"(existing: {self.existing}, new: {self.issued})"
61
+ else:
62
+ return f"(existing: {self.existing})"
63
+
64
+ def to_json(self):
65
+ return {
66
+ "existing": [x.to_json() for x in self.existing],
67
+ "issued": [x.to_json() for x in self.issued],
68
+ }
File without changes
@@ -0,0 +1,155 @@
1
+ import time
2
+ from typing import List, Literal, Optional, Tuple, Union, Dict
3
+
4
+ from certapi import crypto
5
+ from ..acme import Challenge
6
+ from ..challenge_store import ChallengeStore
7
+
8
+ from ..issuers import AcmeCertIssuer,CertIssuer
9
+ from ..http.types import CertificateResponse, IssuedCert
10
+ from ..keystore.KeyStore import KeyStore
11
+ from cryptography.x509 import Certificate,CertificateSigningRequest
12
+ from ..crypto import Key,certs_to_pem, cert_to_pem,get_csr_hostnames
13
+
14
+
15
+ class AcmeCertManager():
16
+ def __init__(
17
+ self,
18
+ key_store: KeyStore,
19
+ cert_issuer: AcmeCertIssuer,
20
+ challenge_stores: List[ChallengeStore] = [],
21
+ ):
22
+ self.key_store: KeyStore = key_store
23
+ self.cert_issuer : AcmeCertIssuer = cert_issuer
24
+ self.challenge_stores : List[ChallengeStore]= challenge_stores
25
+ print(f"AcmeCertManager initialized with challenge_stores: {self.challenge_stores}")
26
+
27
+ def setup(self):
28
+ self.cert_issuer.setup()
29
+
30
+ def issue_certificate_for_csr(self, csr: CertificateSigningRequest)->str:
31
+ """
32
+ Returns Certificate
33
+ """
34
+ hostnames = get_csr_hostnames(csr)
35
+ if not hostnames:
36
+ raise ValueError("CSR does not contain any hostnames.")
37
+
38
+ # Find a challenge store that supports all hostnames in the CSR
39
+ selected_challenge_store = None
40
+ for store in self.challenge_stores:
41
+ if all(store.supports_domain(h) for h in hostnames):
42
+ selected_challenge_store = store
43
+ break
44
+
45
+ if selected_challenge_store is None:
46
+ raise ValueError(f"No challenge store found that supports all domains: {hostnames}")
47
+
48
+ fullchain_cert = self.cert_issuer.sign_csr(csr, challenge_store=selected_challenge_store)
49
+ if fullchain_cert:
50
+ # Assuming the private key associated with the CSR is not managed by CertManager directly
51
+ # and is handled by the caller or the cert_issuer's internal process.
52
+ # For now, we'll just return the certificate.
53
+ # If key saving is required here, the private key would need to be passed along with the CSR.
54
+ return fullchain_cert
55
+ else:
56
+ return None
57
+
58
+ def issue_certificate(self, hosts: Union[str, List[str]],
59
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
60
+ expiry_days: int = 90,
61
+ country: Optional[str] = None,
62
+ state: Optional[str] = None,
63
+ locality: Optional[str] = None,
64
+ organization: Optional[str] = None,
65
+ user_id: Optional[str] = None) -> CertificateResponse:
66
+
67
+ if type(hosts) == str:
68
+ hosts = [hosts]
69
+
70
+ existing : Dict[str, Tuple[int | str, Key, List[Certificate] | str]]= {}
71
+ for h in hosts:
72
+ result = self.key_store.find_key_and_cert_by_domain(h)
73
+ if result is not None:
74
+ # result is (domain_id, key, cert_list)
75
+ existing[h] = result # Store the certificate list
76
+
77
+ missing = [h for h in hosts if h not in existing]
78
+ if len(missing) > 0:
79
+ issued_certs_list = []
80
+ # Group missing hosts by the challenge store that supports them
81
+ domains_by_store: Dict[ChallengeStore, List[str]] = {}
82
+ for host in missing:
83
+ found_store = None
84
+ for store in self.challenge_stores:
85
+ print(f"Debug: Checking store {store} for domain {host}. supports_domain: {store.supports_domain(host)}")
86
+ if store.supports_domain(host):
87
+ print(f"Supports domain: '{host}'",)
88
+ found_store = store
89
+ break
90
+ if found_store is not None:
91
+ if found_store not in domains_by_store:
92
+ domains_by_store[found_store] = []
93
+ domains_by_store[found_store].append(host)
94
+ else:
95
+ print(f"Debug: Current challenge_stores: {self.challenge_stores}")
96
+ print(f"Warning: No challenge store found that supports domain: {host}. Skipping.")
97
+
98
+ # original_challenge_store = self.cert_issuer.challenge_store # Store original
99
+
100
+ for store, domains_to_issue in domains_by_store.items():
101
+
102
+ private_key, fullchain_cert = self.cert_issuer.generate_key_and_cert_for_domains(
103
+ domains_to_issue,
104
+ key_type=key_type,
105
+ expiry_days=expiry_days,
106
+ country=country,
107
+ state=state,
108
+ locality=locality,
109
+ organization=organization,
110
+ user_id=user_id,
111
+ challenge_store=store
112
+
113
+ )
114
+
115
+ if fullchain_cert:
116
+ key_id = self.key_store.save_key(private_key, domains_to_issue[0])
117
+ self.key_store.save_cert(key_id, fullchain_cert, domains_to_issue)
118
+ issued_certs_list.append(IssuedCert(key=private_key, cert=fullchain_cert, domains=domains_to_issue))
119
+ else:
120
+ print(f"Failed to issue certificate for domains: {domains_to_issue}")
121
+
122
+ # self.cert_issuer.challenge_store = original_challenge_store # Restore original
123
+ return createExistingResponse(existing, issued_certs_list)
124
+
125
+ else:
126
+ return createExistingResponse(existing, [])
127
+
128
+
129
+ def createExistingResponse(
130
+ existing: Dict[str, Tuple[int | str, Key, List[Certificate] | str]], issued_certs: List[IssuedCert]
131
+ ):
132
+ certs = []
133
+ certMap = {}
134
+
135
+ for h, (id, key, cert) in existing.items():
136
+ if id in certMap:
137
+ certMap[id][0].append(h)
138
+ else:
139
+ if isinstance(cert, str):
140
+ cert_pem = cert
141
+ elif isinstance(cert, list):
142
+ cert_pem = certs_to_pem(cert).decode("utf-8")
143
+ else:
144
+ cert_pem = cert_to_pem(cert).decode("utf-8")
145
+
146
+ certMap[id] = (
147
+ [h],
148
+ key,
149
+ cert_pem,
150
+ )
151
+
152
+ for hosts, key, cert in certMap.values():
153
+ certs.append(IssuedCert(key=key, cert=cert, domains=hosts))
154
+
155
+ return CertificateResponse(existing=certs, issued=issued_certs)
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: certapi
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -33,7 +33,7 @@ import json
33
33
  from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
34
34
 
35
35
  key_store = FilesystemKeyStore("data")
36
- challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/acme-challenges.
36
+ challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
37
37
 
38
38
  certAuthority = CertAuthority(challenge_store, key_store)
39
39
  certAuthority.setup()
@@ -22,11 +22,20 @@ src/certapi/challenge_store/FilesystemChallengeStore.py
22
22
  src/certapi/challenge_store/InmemoryChallengeStore.py
23
23
  src/certapi/challenge_store/__init__.py
24
24
  src/certapi/challenge_store/dns/__init__.py
25
+ src/certapi/challenge_store/dns/cloudflare/__init__.py
26
+ src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py
27
+ src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py
28
+ src/certapi/challenge_store/dns/digitalocean/__init__.py
29
+ src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py
30
+ src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py
25
31
  src/certapi/client/__init__.py
26
32
  src/certapi/client/cert_manager_client.py
27
33
  src/certapi/crypto/__init__.py
28
34
  src/certapi/crypto/crypto.py
29
35
  src/certapi/crypto/crypto_classes.py
36
+ src/certapi/http/__init__.py
37
+ src/certapi/http/client.py
38
+ src/certapi/http/types.py
30
39
  src/certapi/issuers/AcmeCertIssuer.py
31
40
  src/certapi/issuers/SelfCertIssuer.py
32
41
  src/certapi/issuers/__init__.py
@@ -37,6 +46,8 @@ src/certapi/keystore/PostgresqlKeyStore.py
37
46
  src/certapi/keystore/RemoteKeyStore.py
38
47
  src/certapi/keystore/SqliteKeyStore.py
39
48
  src/certapi/keystore/__init__.py
49
+ src/certapi/manager/__init__.py
50
+ src/certapi/manager/acme_cert_manager.py
40
51
  src/certapi/server/__init__.py
41
52
  src/certapi/server/api.py
42
53
  src/certapi/server/cert_api.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes