certapi 0.2.2__tar.gz → 0.2.4__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 (24) hide show
  1. {certapi-0.2.2 → certapi-0.2.4}/PKG-INFO +3 -1
  2. {certapi-0.2.2 → certapi-0.2.4}/README.md +2 -0
  3. {certapi-0.2.2 → certapi-0.2.4}/setup.py +1 -1
  4. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/Acme.py +5 -4
  5. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/certauthority.py +8 -4
  6. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/cloudflare_challenge_store.py +1 -1
  7. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/crypto.py +5 -0
  8. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/db.py +25 -15
  9. certapi-0.2.4/src/certapi/digitalocean_challenge_store.py +57 -0
  10. certapi-0.2.4/src/certapi/digitalocean_client.py +86 -0
  11. {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/PKG-INFO +3 -1
  12. {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/SOURCES.txt +2 -0
  13. {certapi-0.2.2 → certapi-0.2.4}/MANIFEST.in +0 -0
  14. {certapi-0.2.2 → certapi-0.2.4}/pyproject.toml +0 -0
  15. {certapi-0.2.2 → certapi-0.2.4}/setup.cfg +0 -0
  16. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/__init__.py +0 -0
  17. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/challenge.py +0 -0
  18. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/cloudflare_client.py +0 -0
  19. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/crypto_classes.py +0 -0
  20. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/custom_certauthority.py +0 -0
  21. {certapi-0.2.2 → certapi-0.2.4}/src/certapi/util.py +0 -0
  22. {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/dependency_links.txt +0 -0
  23. {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/requires.txt +0 -0
  24. {certapi-0.2.2 → certapi-0.2.4}/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.2.2
3
+ Version: 0.2.4
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
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
16
16
  CertApi is a Python package for requesting SSL certificates from ACME.
17
17
  This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
18
18
 
19
+ > ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
20
+
19
21
  ## Installation
20
22
 
21
23
  You can install CertApi using pip:
@@ -3,6 +3,8 @@
3
3
  CertApi is a Python package for requesting SSL certificates from ACME.
4
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
5
 
6
+ > ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
7
+
6
8
  ## Installation
7
9
 
8
10
  You can install CertApi using pip:
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.2.2",
5
+ version="0.2.4",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import re
3
3
  import threading
4
+ import time
4
5
  from typing import Union, List, Tuple
5
6
  import json
6
7
  from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
@@ -81,7 +82,6 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
81
82
  "resolved": validation_record["addressesResolved"],
82
83
  "used": validation_record["addressUsed"],
83
84
  }
84
- err_detail: str = error["detail"]
85
85
  if error["type"] == "urn:ietf:params:acme:error:connection":
86
86
  if "Timeout during connect" in err_detail:
87
87
  error["connect"] = {"error": "Timeout"}
@@ -276,6 +276,7 @@ class Acme:
276
276
  response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
277
277
  except AcmeError as e:
278
278
  if e.can_retry and depth <= 0:
279
+ time.sleep(2)
279
280
  return self._signed_req(url, payload, depth + 1, step, throw)
280
281
  else:
281
282
  raise e
@@ -325,7 +326,7 @@ class Order:
325
326
  self.status = self._data["status"]
326
327
  return response
327
328
 
328
- def get_certificate(self) -> Certificate:
329
+ def get_certificate(self) -> Tuple[str, List[Certificate]]:
329
330
  if self.status == "processing":
330
331
  raise ValueError(
331
332
  "Order is still in 'processing' state! Wait until the order is finalized, and call `Order.refresh()` to update the state"
@@ -336,8 +337,8 @@ class Order:
336
337
  certificate_res = self._acme._signed_req(
337
338
  self._data["certificate"], step="Get Certificate from Successful Order"
338
339
  )
339
- certificate = crypto.x509.load_pem_x509_certificate(certificate_res.content)
340
- return certificate
340
+ certificate = crypto.x509.load_pem_x509_certificates(certificate_res.content)
341
+ return ( certificate_res.content, certificate)
341
342
 
342
343
  def finalize(self, csr: CertificateSigningRequest):
343
344
  """
@@ -121,10 +121,10 @@ class CertAuthority:
121
121
  order.refresh() # is this refresh necessary?
122
122
 
123
123
  if order.status == "valid":
124
- certificate = order.get_certificate()
124
+ fullchain_cert,certificate = order.get_certificate()
125
125
  key_id = self.key_store.save_key(private_key, missing[0])
126
126
  cert_id = self.key_store.save_cert(key_id, certificate, missing)
127
- issued_cert = IssuedCert(key_to_pem(private_key), certificate, missing)
127
+ issued_cert = IssuedCert(key_to_pem(private_key), fullchain_cert, missing)
128
128
  # Clean up challenges after successful certificate issuance
129
129
  for c in challenges:
130
130
  challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
@@ -145,24 +145,28 @@ class CertAuthority:
145
145
  return createExistingResponse(existing, [])
146
146
 
147
147
 
148
- def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate]], issued_certs: List["IssuedCert"]):
148
+ def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate | str]], issued_certs: List["IssuedCert"]):
149
149
  certs = []
150
150
  certMap = {}
151
+
151
152
  for h, (id, key, cert) in existing.items():
152
153
  if id in certMap:
153
154
  certMap[id][0].append(h)
154
155
  else:
156
+ cert_pem = cert if isinstance(cert, str) else cert_to_pem(cert).decode("utf-8")
155
157
  certMap[id] = (
156
158
  [h],
157
159
  key.to_pem().decode("utf-8"),
158
- cert_to_pem(cert).decode("utf-8"),
160
+ cert_pem,
159
161
  )
162
+
160
163
  for hosts, key, cert in certMap.values():
161
164
  certs.append(IssuedCert(key, cert, hosts))
162
165
 
163
166
  return CertificateResponse(certs, issued_certs)
164
167
 
165
168
 
169
+
166
170
  class CertificateResponse:
167
171
  def __init__(self, existing, issued):
168
172
  self.existing: List[IssuedCert] = existing
@@ -7,7 +7,7 @@ from certapi.cloudflare_client import Cloudflare
7
7
  class CloudflareChallengeStore(ChallengeStore):
8
8
  def __init__(self):
9
9
  self.cloudflare = Cloudflare()
10
- self.challenges_map = {} # Stores key: record_id (still needed for deletion)
10
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
11
11
 
12
12
  def has_domain(self, domain: str) -> bool:
13
13
  """
@@ -106,6 +106,11 @@ def key_from_pem(data: bytes):
106
106
  def cert_from_pem(data: bytes) -> Certificate:
107
107
  return x509.load_pem_x509_certificate(data)
108
108
 
109
+ def certs_from_pem(data: bytes) -> List[Certificate]:
110
+ return x509.load_pem_x509_certificates(data)
111
+
112
+ def cert_to_der(cert):
113
+ return cert.public_bytes(serialization.Encoding.DER)
109
114
 
110
115
  def cert_to_pem(cert):
111
116
  return cert.public_bytes(serialization.Encoding.PEM)
@@ -21,7 +21,7 @@ class KeyStore(ABC):
21
21
  pass
22
22
 
23
23
  @abstractmethod
24
- def save_cert(self, private_key_id: int, cert: Certificate, domains: List[str], name: str = None) -> int:
24
+ def save_cert(self, private_key_id: int, cert: Certificate|str, domains: List[str], name: str = None) -> int:
25
25
  pass
26
26
 
27
27
  @abstractmethod
@@ -91,12 +91,12 @@ class SqliteKeyStore(KeyStore):
91
91
  self.save_key(key, name)
92
92
  return key
93
93
 
94
- def save_cert(self, private_key_id: int, cert: Certificate, domains: List[str], name: str = None) -> int:
94
+ def save_cert(self, private_key_id: int, cert: Certificate|str, domains: List[str], name: str = None) -> int:
95
95
  conn = self._get_db_connection()
96
96
  cur = conn.cursor()
97
97
  cur.execute(
98
98
  "INSERT INTO certificates (name, priv_id, content) VALUES (?, ?, ?)",
99
- (name, private_key_id, cert.public_bytes(serialization.Encoding.DER)),
99
+ (name, private_key_id, cert_to_der(cert)),
100
100
  )
101
101
  cert_id = cur.lastrowid
102
102
 
@@ -182,32 +182,42 @@ class FilesystemKeyStore(KeyStore):
182
182
  return cert_from_pem(cert_data)
183
183
  return None
184
184
 
185
- def save_cert(self, private_key_id: str, cert: Certificate, domains: list, name: str = None) -> int:
185
+
186
+ def save_cert(self, private_key_id: str, cert: Certificate | str, domains: list, name: str = None) -> int:
187
+ def get_cert_pem(cert) -> bytes:
188
+ return cert.encode() if isinstance(cert, str) else cert_to_pem(cert)
189
+
190
+ cert_pem = get_cert_pem(cert)
191
+
186
192
  if name:
187
193
  cert_path = os.path.join(self.certs_dir, f"{name}.crt")
188
194
  with open(cert_path, "wb") as f:
189
- f.write(cert_to_pem(cert))
195
+ f.write(cert_pem)
196
+
190
197
  key_content = None
191
198
  key_path = os.path.join(self.keys_dir, f"{private_key_id}.key")
192
199
  with open(key_path, "rb") as f:
193
200
  key_content = f.read()
194
-
201
+
195
202
  for domain in domains:
203
+ domain_name = domain
196
204
  if name is not None and name.endswith(".selfsigned"):
197
- domain = domain + ".selfsigned"
205
+ domain_name += ".selfsigned"
198
206
 
199
- if domain != private_key_id:
200
- with open(os.path.join(self.keys_dir, f"{domain}.key"), "wb") as f:
207
+ if domain_name != private_key_id:
208
+ with open(os.path.join(self.keys_dir, f"{domain_name}.key"), "wb") as f:
201
209
  f.write(key_content)
202
- domain_cert_path = os.path.join(self.certs_dir, f"{domain}.crt")
210
+
211
+ domain_cert_path = os.path.join(self.certs_dir, f"{domain_name}.crt")
203
212
  with open(domain_cert_path, "wb") as f:
204
- f.write(cert_to_pem(cert))
213
+ f.write(cert_pem)
214
+
205
215
 
206
216
  return name if name else domains[0] # Dummy ID since filesystem does not use numeric IDs
207
217
 
208
- def get_cert(self, domain: str) -> None | Tuple[str, Key, Certificate]:
209
- cert_path = os.path.join(self.certs_dir, f"{domain}.crt")
210
- key_path = os.path.join(self.keys_dir, f"{domain}.key")
218
+ def get_cert(self, name: str) -> None | Tuple[str, Key, Certificate]:
219
+ cert_path = os.path.join(self.certs_dir, f"{name}.crt")
220
+ key_path = os.path.join(self.keys_dir, f"{name}.key")
211
221
  key = None
212
222
  cert = None
213
223
  if os.path.exists(key_path):
@@ -226,7 +236,7 @@ class FilesystemKeyStore(KeyStore):
226
236
 
227
237
  if cert is None or key is None:
228
238
  return None
229
- return (domain, key, cert)
239
+ return (name, key, cert)
230
240
 
231
241
 
232
242
  class PostgresKeyStore(KeyStore):
@@ -0,0 +1,57 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from certapi.challenge import ChallengeStore
4
+ from certapi.digitalocean_client import DigitalOcean
5
+
6
+
7
+ class DigitalOceanChallengeStore(ChallengeStore):
8
+ def __init__(self):
9
+ self.digitalocean = DigitalOcean()
10
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
11
+
12
+ def has_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,86 @@
1
+ import json
2
+ from os import getenv
3
+
4
+ from urllib.request import urlopen, Request # Python 3
5
+
6
+
7
+
8
+ class DigitalOcean(object):
9
+ def __init__(self,api_key=None):
10
+ self.token=api_key
11
+ self.api = "https://api.digitalocean.com/v2/domains"
12
+ if not self.token:
13
+ self.token = getenv("DIGITALOCEAN_API_TOKEN")
14
+ if not self.token:
15
+ raise Exception("API_TOKEN not found in environment")
16
+
17
+ def determine_domain(self, domain):
18
+ """Determine registered domain in API"""
19
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
20
+ response = urlopen(Request(self.api, headers=request_headers))
21
+ if response.getcode() != 200:
22
+ raise Exception(json.loads(response.read().decode("utf8")))
23
+ domains = json.loads(response.read().decode("utf8"))["domains"]
24
+ for d in domains:
25
+ if d["name"] in domain:
26
+ return d["name"]
27
+
28
+ def create_record(self, name, data, domain):
29
+ """
30
+ Create DNS record
31
+ Params:
32
+ name, string, record name
33
+ data, string, record data
34
+ domain, string, dns domain
35
+ Return:
36
+ record_id, int, created record id
37
+ """
38
+ registered_domain = self.determine_domain(domain)
39
+ api = self.api + "/" + registered_domain + "/records"
40
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
41
+ request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data}
42
+ response = urlopen(Request(api, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
43
+ if response.getcode() != 201:
44
+ raise Exception(json.loads(response.read().decode("utf8")))
45
+ return json.loads(response.read().decode("utf8"))["domain_record"]["id"]
46
+
47
+ def delete_record(self, record, domain):
48
+ """
49
+ Delete DNS record
50
+ Params:
51
+ record, int, record id number
52
+ domain, string, dns domain
53
+ """
54
+ registered_domain = self.determine_domain(domain)
55
+ api = self.api + "/" + registered_domain + "/records/" + str(record)
56
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
57
+ request = Request(api, data=json.dumps({}).encode("utf8"), headers=request_headers)
58
+ request.get_method = lambda: "DELETE"
59
+ response = urlopen(request)
60
+ if response.getcode() != 204:
61
+ raise Exception(json.loads(response.read().decode("utf8")))
62
+
63
+ def list_records(self, domain, name_filter=None):
64
+ """
65
+ List DNS records for a domain, optionally filtered by name.
66
+ Params:
67
+ domain, string, dns domain
68
+ name_filter, string, optional filter for record name
69
+ Return:
70
+ records, list of dicts, matching DNS records
71
+ """
72
+ registered_domain = self.determine_domain(domain)
73
+ api = self.api + "/" + registered_domain + "/records"
74
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
75
+ response = urlopen(Request(api, headers=request_headers))
76
+ if response.getcode() != 200:
77
+ raise Exception(json.loads(response.read().decode("utf8")))
78
+
79
+ all_records = json.loads(response.read().decode("utf8"))["domain_records"]
80
+
81
+ if name_filter:
82
+ filtered_records = [r for r in all_records if r["name"] == name_filter and r["type"] == "TXT"]
83
+ else:
84
+ filtered_records = [r for r in all_records if r["type"] == "TXT"]
85
+
86
+ return filtered_records
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: certapi
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
16
16
  CertApi is a Python package for requesting SSL certificates from ACME.
17
17
  This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
18
18
 
19
+ > ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
20
+
19
21
  ## Installation
20
22
 
21
23
  You can install CertApi using pip:
@@ -12,6 +12,8 @@ src/certapi/crypto.py
12
12
  src/certapi/crypto_classes.py
13
13
  src/certapi/custom_certauthority.py
14
14
  src/certapi/db.py
15
+ src/certapi/digitalocean_challenge_store.py
16
+ src/certapi/digitalocean_client.py
15
17
  src/certapi/util.py
16
18
  src/certapi.egg-info/PKG-INFO
17
19
  src/certapi.egg-info/SOURCES.txt
File without changes
File without changes
File without changes
File without changes
File without changes