certapi 0.5.0__tar.gz → 0.6.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.5.0/src/certapi.egg-info → certapi-0.6.0}/PKG-INFO +13 -2
- {certapi-0.5.0 → certapi-0.6.0}/pyproject.toml +0 -1
- {certapi-0.5.0 → certapi-0.6.0}/setup.py +1 -1
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/AcmeError.py +22 -13
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/http.py +1 -1
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/errors.py +1 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/http/HttpClientBase.py +6 -5
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/manager/acme_cert_manager.py +6 -2
- {certapi-0.5.0 → certapi-0.6.0/src/certapi.egg-info}/PKG-INFO +13 -2
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi.egg-info/SOURCES.txt +5 -1
- certapi-0.6.0/tests/test_cert_issuer_generic.py +202 -0
- certapi-0.6.0/tests/test_certs_with_key_types.py +41 -0
- certapi-0.6.0/tests/test_http_error_handling.py +140 -0
- certapi-0.6.0/tests/test_keystores.py +181 -0
- {certapi-0.5.0 → certapi-0.6.0}/MANIFEST.in +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/README.md +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/setup.cfg +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/Acme.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/Challenge.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/Order.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/acme/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/client/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/crypto/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/crypto/crypto.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/http/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/http/types.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/issuers/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/keystore/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/manager/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/server/__init__.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/server/api.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/server/cert_api.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/server/key_api.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/util.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi/utils.py +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.5.0 → certapi-0.6.0}/src/certapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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,6 +10,17 @@ 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
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
Dynamic: summary
|
|
13
24
|
|
|
14
25
|
# CertApi
|
|
15
26
|
|
|
@@ -61,30 +61,36 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
61
61
|
message = err_detail
|
|
62
62
|
else:
|
|
63
63
|
validation_record = validation_record[0]
|
|
64
|
-
error["hostname"] = validation_record
|
|
64
|
+
error["hostname"] = validation_record.get("hostname", "unknown")
|
|
65
65
|
error["dns"] = {
|
|
66
|
-
"resolved": validation_record
|
|
67
|
-
"used": validation_record
|
|
66
|
+
"resolved": validation_record.get("addressesResolved"),
|
|
67
|
+
"used": validation_record.get("addressUsed"),
|
|
68
68
|
}
|
|
69
69
|
if err_type == "urn:ietf:params:acme:error:connection":
|
|
70
70
|
if "Timeout during connect" in err_detail:
|
|
71
71
|
error["connect"] = {"error": "Timeout"}
|
|
72
|
+
address_used = validation_record.get("addressUsed", "unknown")
|
|
73
|
+
port = str(validation_record.get("port", "unknown"))
|
|
74
|
+
hostname = error.get("hostname", "unknown")
|
|
72
75
|
message = (
|
|
73
|
-
|
|
76
|
+
str(hostname)
|
|
74
77
|
+ "["
|
|
75
|
-
+
|
|
78
|
+
+ address_used
|
|
76
79
|
+ ":"
|
|
77
|
-
+
|
|
80
|
+
+ port
|
|
78
81
|
+ "] Connect Timeout (Maybe firewall reasons)"
|
|
79
82
|
)
|
|
80
83
|
elif err_detail.endswith("Connection refused"):
|
|
81
84
|
error["connect"] = {"error": "connection refused"}
|
|
85
|
+
address_used = validation_record.get("addressUsed", "unknown")
|
|
86
|
+
port = str(validation_record.get("port", "unknown"))
|
|
87
|
+
hostname = error.get("hostname", "unknown")
|
|
82
88
|
message = (
|
|
83
|
-
|
|
89
|
+
str(hostname)
|
|
84
90
|
+ "["
|
|
85
|
-
+
|
|
91
|
+
+ address_used
|
|
86
92
|
+ ":"
|
|
87
|
-
+
|
|
93
|
+
+ port
|
|
88
94
|
+ "] Connection Refused (Is http server running?)"
|
|
89
95
|
)
|
|
90
96
|
elif err_detail:
|
|
@@ -98,11 +104,13 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
98
104
|
|
|
99
105
|
if match:
|
|
100
106
|
error["response"] = (match.group(1) if match is not None else err_detail,)
|
|
101
|
-
error["status_code"] = (error
|
|
107
|
+
error["status_code"] = (error.get("status"),)
|
|
108
|
+
hostname = error.get("hostname", "unknown")
|
|
109
|
+
status = error.get("status", "unknown")
|
|
102
110
|
message = (
|
|
103
|
-
|
|
111
|
+
str(hostname)
|
|
104
112
|
+ " Status="
|
|
105
|
-
+
|
|
113
|
+
+ str(status)
|
|
106
114
|
+ ": Invalid response in challenge url"
|
|
107
115
|
)
|
|
108
116
|
else:
|
|
@@ -131,7 +139,8 @@ class AcmeInvaliOrderError(AcmeHttpError):
|
|
|
131
139
|
super().__init__(response, step)
|
|
132
140
|
|
|
133
141
|
|
|
134
|
-
class
|
|
142
|
+
class AcmeInvalidNonceError(AcmeHttpError):
|
|
135
143
|
def __init__(self, response: requests.Response, step: str):
|
|
136
144
|
super().__init__(response, step)
|
|
137
145
|
self.can_retry = True
|
|
146
|
+
self.response=0
|
|
@@ -34,7 +34,7 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
|
|
|
34
34
|
if json_data and json_data.get("type"):
|
|
35
35
|
errorType = json_data["type"]
|
|
36
36
|
if errorType == "urn:ietf:params:acme:error:badNonce":
|
|
37
|
-
raise
|
|
37
|
+
raise AcmeInvalidNonceError(res, step=step)
|
|
38
38
|
|
|
39
39
|
if throw:
|
|
40
40
|
raise AcmeHttpError(res, step=step)
|
|
@@ -12,6 +12,7 @@ class CertApiException(Exception):
|
|
|
12
12
|
self.detail = detail if detail is not None else {}
|
|
13
13
|
self.step = step
|
|
14
14
|
self.can_retry = False
|
|
15
|
+
self.retry_delay = 4 # Default retry delay in seconds
|
|
15
16
|
|
|
16
17
|
def json_obj(self) -> dict:
|
|
17
18
|
return {"name": self.__class__.__name__, "message": self.message, "step": self.step, "detail": self.detail}
|
|
@@ -24,7 +24,7 @@ class HttpClientBase(object):
|
|
|
24
24
|
try:
|
|
25
25
|
res = self.session.request(method, url, json=json_data, data=data, params=params, timeout=timeout)
|
|
26
26
|
except requests.exceptions.ConnectionError as e:
|
|
27
|
-
print("Request [
|
|
27
|
+
print("Request [Connection Error] : " + method + " " + url + " step=" + str(step))
|
|
28
28
|
raise NetworkError(
|
|
29
29
|
request=e.request,
|
|
30
30
|
message=f"Network connection error: {e}",
|
|
@@ -32,7 +32,7 @@ class HttpClientBase(object):
|
|
|
32
32
|
step=f"HTTP Request ({method} {url})" if step is None else step,
|
|
33
33
|
) from e
|
|
34
34
|
except requests.exceptions.Timeout as e:
|
|
35
|
-
print("Request [
|
|
35
|
+
print("Request [Timeout] : " + method + " " + url + " step=" + str(step))
|
|
36
36
|
raise NetworkError(
|
|
37
37
|
request=e.request,
|
|
38
38
|
message=f"Request timed out: {e}",
|
|
@@ -40,7 +40,7 @@ class HttpClientBase(object):
|
|
|
40
40
|
step=f"HTTP Request ({method} {url})" if step is None else step,
|
|
41
41
|
) from e
|
|
42
42
|
except requests.exceptions.RequestException as e:
|
|
43
|
-
print("Request [
|
|
43
|
+
print("Request [Request Exception] : " + method + " " + url + " step=" + str(step))
|
|
44
44
|
raise NetworkError(
|
|
45
45
|
request=e.request,
|
|
46
46
|
message=f"An unexpected network error occurred: {e}",
|
|
@@ -80,8 +80,9 @@ class HttpClientBase(object):
|
|
|
80
80
|
return self._req(method, url, step, json_data, data, params, timeout)
|
|
81
81
|
except CertApiException as e:
|
|
82
82
|
if e.can_retry and i < retries:
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
retry_delay = e.retry_delay if hasattr(e, 'retry_delay') else delay
|
|
84
|
+
print(f"Retrying {method} {url} in {retry_delay} seconds due to retryable error: {e.message}")
|
|
85
|
+
time.sleep(retry_delay)
|
|
85
86
|
else:
|
|
86
87
|
e.can_retry = False
|
|
87
88
|
raise
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import time
|
|
2
2
|
from typing import List, Literal, Optional, Tuple, Union, Dict
|
|
3
|
+
from datetime import datetime, timezone, timedelta
|
|
3
4
|
|
|
4
5
|
from certapi import crypto
|
|
5
6
|
from ..acme import Challenge
|
|
@@ -76,8 +77,11 @@ class AcmeCertManager:
|
|
|
76
77
|
result = self.key_store.find_key_and_cert_by_domain(h)
|
|
77
78
|
if result is not None:
|
|
78
79
|
# result is (domain_id, key, cert_list)
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
cert=result[2][0]
|
|
81
|
+
invalid_date = cert.not_valid_after_utc
|
|
82
|
+
# Check if the certificate is still valid for at least 30 days
|
|
83
|
+
if invalid_date > datetime.now(timezone.utc) + timedelta(days=6):
|
|
84
|
+
existing[h] = result
|
|
81
85
|
missing = [h for h in hosts if h not in existing]
|
|
82
86
|
if len(missing) > 0:
|
|
83
87
|
issued_certs_list = []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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,6 +10,17 @@ 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
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
Dynamic: summary
|
|
13
24
|
|
|
14
25
|
# CertApi
|
|
15
26
|
|
|
@@ -51,4 +51,8 @@ src/certapi/manager/acme_cert_manager.py
|
|
|
51
51
|
src/certapi/server/__init__.py
|
|
52
52
|
src/certapi/server/api.py
|
|
53
53
|
src/certapi/server/cert_api.py
|
|
54
|
-
src/certapi/server/key_api.py
|
|
54
|
+
src/certapi/server/key_api.py
|
|
55
|
+
tests/test_cert_issuer_generic.py
|
|
56
|
+
tests/test_certs_with_key_types.py
|
|
57
|
+
tests/test_http_error_handling.py
|
|
58
|
+
tests/test_keystores.py
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
3
|
+
from cryptography import x509
|
|
4
|
+
from cryptography.x509.oid import NameOID, ExtensionOID
|
|
5
|
+
from certapi import Key, SelfCertIssuer, Certificate, CertificateSigningRequest, CertificateSigningRequestBuilder
|
|
6
|
+
from certapi.issuers.abstract_certissuer import CertIssuer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(scope="module")
|
|
10
|
+
def self_cert_issuer_instance():
|
|
11
|
+
"""Fixture to provide a SelfCertIssuer instance for testing."""
|
|
12
|
+
ca_key = Key.generate("rsa")
|
|
13
|
+
issuer = SelfCertIssuer(
|
|
14
|
+
ca_key,
|
|
15
|
+
country="US",
|
|
16
|
+
state="California",
|
|
17
|
+
locality="Los Angeles",
|
|
18
|
+
organization="TestOrg",
|
|
19
|
+
common_name="testca.local",
|
|
20
|
+
)
|
|
21
|
+
return issuer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize("key_type", ["rsa", "ecdsa", "ed25519"])
|
|
25
|
+
def test_generate_key_and_cert(self_cert_issuer_instance: SelfCertIssuer, key_type: str):
|
|
26
|
+
"""
|
|
27
|
+
Test the generate_key_and_cert method of CertIssuer using SelfCertIssuer.
|
|
28
|
+
"""
|
|
29
|
+
domain = "example.com"
|
|
30
|
+
alt_names = ["www.example.com", "mail.example.com"]
|
|
31
|
+
expiry_days = 30
|
|
32
|
+
|
|
33
|
+
new_key, cert = self_cert_issuer_instance.generate_key_and_cert(
|
|
34
|
+
domain=domain,
|
|
35
|
+
alt_names=alt_names,
|
|
36
|
+
key_type=key_type,
|
|
37
|
+
expiry_days=expiry_days,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
assert isinstance(new_key, Key)
|
|
41
|
+
assert isinstance(cert, x509.Certificate)
|
|
42
|
+
|
|
43
|
+
# Verify subject and issuer
|
|
44
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == domain
|
|
45
|
+
assert cert.issuer == self_cert_issuer_instance.issuer
|
|
46
|
+
|
|
47
|
+
# Verify SAN extension
|
|
48
|
+
san_extension = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
|
49
|
+
san_values = san_extension.value.get_values_for_type(x509.DNSName)
|
|
50
|
+
expected_sans = [domain] + alt_names
|
|
51
|
+
# The domain is now always included in SAN by create_csr, so ensure it's unique and first
|
|
52
|
+
unique_expected_sans = []
|
|
53
|
+
seen = set()
|
|
54
|
+
for name in expected_sans:
|
|
55
|
+
if name not in seen:
|
|
56
|
+
seen.add(name)
|
|
57
|
+
unique_expected_sans.append(name)
|
|
58
|
+
assert set(san_values) == set(unique_expected_sans)
|
|
59
|
+
|
|
60
|
+
# Verify expiry
|
|
61
|
+
now_utc = datetime.now(UTC)
|
|
62
|
+
# Ensure certificate validity dates are timezone-aware UTC for comparison
|
|
63
|
+
cert_not_valid_before_utc = cert.not_valid_before_utc.astimezone(timezone.utc)
|
|
64
|
+
cert_not_valid_after_utc = cert.not_valid_after_utc.astimezone(timezone.utc)
|
|
65
|
+
|
|
66
|
+
assert cert_not_valid_before_utc <= now_utc
|
|
67
|
+
assert cert_not_valid_after_utc >= now_utc + timedelta(days=expiry_days - 1) # Allow for slight time difference
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("key_type", ["rsa", "ecdsa", "ed25519"])
|
|
71
|
+
def test_generate_key_and_cert_no_alt_names(self_cert_issuer_instance: SelfCertIssuer, key_type: str):
|
|
72
|
+
"""
|
|
73
|
+
Test generate_key_and_cert with no alt_names provided.
|
|
74
|
+
"""
|
|
75
|
+
domain = "no-alt-names.example.com"
|
|
76
|
+
new_key, cert = self_cert_issuer_instance.generate_key_and_cert(
|
|
77
|
+
domain=domain,
|
|
78
|
+
alt_names=[],
|
|
79
|
+
key_type=key_type,
|
|
80
|
+
)
|
|
81
|
+
assert isinstance(new_key, Key)
|
|
82
|
+
assert isinstance(cert, x509.Certificate)
|
|
83
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == domain
|
|
84
|
+
san_extension = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
|
85
|
+
san_values = san_extension.value.get_values_for_type(x509.DNSName)
|
|
86
|
+
assert set(san_values) == {domain}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize("key_type", ["rsa", "ecdsa", "ed25519"])
|
|
90
|
+
def test_generate_key_and_cert_with_custom_fields(self_cert_issuer_instance: SelfCertIssuer, key_type: str):
|
|
91
|
+
"""
|
|
92
|
+
Test generate_key_and_cert with custom country, state, locality, organization, and user_id.
|
|
93
|
+
"""
|
|
94
|
+
domain = "custom.example.com"
|
|
95
|
+
country = "CA"
|
|
96
|
+
state = "Ontario"
|
|
97
|
+
locality = "Toronto"
|
|
98
|
+
organization = "CustomOrg"
|
|
99
|
+
user_id = "custom_user_id"
|
|
100
|
+
|
|
101
|
+
new_key, cert = self_cert_issuer_instance.generate_key_and_cert(
|
|
102
|
+
domain=domain,
|
|
103
|
+
key_type=key_type,
|
|
104
|
+
country=country,
|
|
105
|
+
state=state,
|
|
106
|
+
locality=locality,
|
|
107
|
+
organization=organization,
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
assert isinstance(new_key, Key)
|
|
112
|
+
assert isinstance(cert, x509.Certificate)
|
|
113
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == domain
|
|
114
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value == country
|
|
115
|
+
assert cert.subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME)[0].value == state
|
|
116
|
+
assert cert.subject.get_attributes_for_oid(NameOID.LOCALITY_NAME)[0].value == locality
|
|
117
|
+
assert cert.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value == organization
|
|
118
|
+
assert cert.subject.get_attributes_for_oid(NameOID.USER_ID)[0].value == user_id
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("key_type", ["rsa", "ecdsa", "ed25519"])
|
|
122
|
+
def test_generate_key_and_cert_for_domain(self_cert_issuer_instance: SelfCertIssuer, key_type: str):
|
|
123
|
+
"""
|
|
124
|
+
Test the generate_key_and_cert_for_domain method.
|
|
125
|
+
"""
|
|
126
|
+
domain = "single.example.com"
|
|
127
|
+
new_key, cert = self_cert_issuer_instance.generate_key_and_cert_for_domain(
|
|
128
|
+
host=domain,
|
|
129
|
+
key_type=key_type,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
assert isinstance(new_key, Key)
|
|
133
|
+
assert isinstance(cert, x509.Certificate)
|
|
134
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == domain
|
|
135
|
+
san_extension = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
|
136
|
+
san_values = san_extension.value.get_values_for_type(x509.DNSName)
|
|
137
|
+
assert set(san_values) == {domain} # Now domain should always be in SAN
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.parametrize("key_type", ["rsa", "ecdsa", "ed25519"])
|
|
141
|
+
def test_generate_key_and_cert_for_domains(self_cert_issuer_instance: SelfCertIssuer, key_type: str):
|
|
142
|
+
"""
|
|
143
|
+
Test the generate_key_and_cert_for_domains method.
|
|
144
|
+
"""
|
|
145
|
+
hosts = ["multi.example.com", "sub.multi.example.com"]
|
|
146
|
+
new_key, cert = self_cert_issuer_instance.generate_key_and_cert_for_domains(
|
|
147
|
+
hosts=hosts,
|
|
148
|
+
key_type=key_type,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
assert isinstance(new_key, Key)
|
|
152
|
+
assert isinstance(cert, x509.Certificate)
|
|
153
|
+
assert cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value == hosts[0]
|
|
154
|
+
san_extension = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
|
155
|
+
san_values = san_extension.value.get_values_for_type(x509.DNSName)
|
|
156
|
+
assert set(san_values) == set(hosts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_generate_key_and_cert_for_domains_empty_hosts(self_cert_issuer_instance: SelfCertIssuer):
|
|
160
|
+
"""
|
|
161
|
+
Test generate_key_and_cert_for_domains with an empty hosts list.
|
|
162
|
+
"""
|
|
163
|
+
with pytest.raises(ValueError, match="empty hosts array provided"):
|
|
164
|
+
self_cert_issuer_instance.generate_key_and_cert_for_domains(hosts=[])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_generate_key_and_cert_unsupported_key_type(self_cert_issuer_instance: SelfCertIssuer):
|
|
168
|
+
"""
|
|
169
|
+
Test generate_key_and_cert with an unsupported key type.
|
|
170
|
+
"""
|
|
171
|
+
with pytest.raises(ValueError, match="Unsupported key type. Use 'rsa' or 'ecdsa'"):
|
|
172
|
+
self_cert_issuer_instance.generate_key_and_cert(
|
|
173
|
+
domain="invalid.example.com",
|
|
174
|
+
key_type="unsupported", # type: ignore
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_get_csr_hostnames():
|
|
179
|
+
"""
|
|
180
|
+
Test the static method get_csr_hostnames.
|
|
181
|
+
"""
|
|
182
|
+
# Case 1: CSR with CN and SAN
|
|
183
|
+
key = Key.generate("rsa")
|
|
184
|
+
csr = key.create_csr(domain="cn.test.com", alt_names=["san1.test.com", "san2.test.com"])
|
|
185
|
+
hostnames = CertIssuer.get_csr_hostnames(csr)
|
|
186
|
+
assert set(hostnames) == {"cn.test.com", "san1.test.com", "san2.test.com"}
|
|
187
|
+
assert hostnames[0] == "cn.test.com" # CN should be first if not already in SAN
|
|
188
|
+
|
|
189
|
+
# Case 2: CSR with only CN (and it will be added to SAN by create_csr)
|
|
190
|
+
key_cn_only = Key.generate("ecdsa")
|
|
191
|
+
csr_cn_only = key_cn_only.create_csr(domain="onlycn.test.com")
|
|
192
|
+
hostnames_cn_only = CertIssuer.get_csr_hostnames(csr_cn_only)
|
|
193
|
+
assert hostnames_cn_only == ["onlycn.test.com"]
|
|
194
|
+
|
|
195
|
+
# Case 3: CN is also in SAN (handled by unique_alt_names logic in create_csr)
|
|
196
|
+
key_cn_in_san = Key.generate("ecdsa")
|
|
197
|
+
csr_cn_in_san = key_cn_in_san.create_csr(
|
|
198
|
+
domain="duplicate.test.com", alt_names=["duplicate.test.com", "another.test.com"]
|
|
199
|
+
)
|
|
200
|
+
hostnames_cn_in_san = CertIssuer.get_csr_hostnames(csr_cn_in_san)
|
|
201
|
+
assert set(hostnames_cn_in_san) == {"duplicate.test.com", "another.test.com"}
|
|
202
|
+
assert hostnames_cn_in_san[0] == "duplicate.test.com" # CN should still be first and unique
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from cryptography import x509
|
|
4
|
+
from certapi import Key, SelfCertIssuer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.parametrize("ca_key_type", ["rsa", "ecdsa", "ed25519"])
|
|
8
|
+
@pytest.mark.parametrize("csr_key_type", ["rsa", "ecdsa", "ed25519"])
|
|
9
|
+
def test_ca_and_leaf_cert_all_key_pairs(ca_key_type, csr_key_type):
|
|
10
|
+
# Generate CA key and CA instance
|
|
11
|
+
ca_key = Key.generate(ca_key_type)
|
|
12
|
+
ca: SelfCertIssuer = SelfCertIssuer(
|
|
13
|
+
ca_key,
|
|
14
|
+
country="US",
|
|
15
|
+
state="California",
|
|
16
|
+
locality="Los Angeles",
|
|
17
|
+
organization="TestOrg",
|
|
18
|
+
common_name="testca.local",
|
|
19
|
+
)
|
|
20
|
+
ca_cert = ca.get_ca_cert()
|
|
21
|
+
|
|
22
|
+
# Generate leaf/CSR key
|
|
23
|
+
leaf_key = Key.generate(csr_key_type)
|
|
24
|
+
csr = leaf_key.create_csr("example.com")
|
|
25
|
+
|
|
26
|
+
# Sign CSR with CA key
|
|
27
|
+
leaf_cert = ca.sign_csr(csr, expiry_days=30)
|
|
28
|
+
|
|
29
|
+
# Assertions
|
|
30
|
+
assert isinstance(ca_cert, x509.Certificate)
|
|
31
|
+
assert ca_cert.subject == ca_cert.issuer # Self-signed
|
|
32
|
+
assert isinstance(ca_cert.not_valid_before_utc, datetime)
|
|
33
|
+
assert isinstance(ca_cert.not_valid_after_utc, datetime)
|
|
34
|
+
assert ca_cert.issuer.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)[0].value == "testca.local"
|
|
35
|
+
|
|
36
|
+
assert isinstance(leaf_cert, x509.Certificate)
|
|
37
|
+
assert leaf_cert.not_valid_after_utc > leaf_cert.not_valid_before_utc
|
|
38
|
+
assert leaf_cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)[0].value == "example.com"
|
|
39
|
+
|
|
40
|
+
# Check issuer consistency
|
|
41
|
+
assert leaf_cert.issuer == ca_cert.subject
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
import requests
|
|
4
|
+
from certapi.http.HttpClientBase import HttpClientBase
|
|
5
|
+
from certapi.errors import NetworkError, CertApiException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestHttpErrorHandling:
|
|
9
|
+
"""Test suite for HTTP error handling in HttpClientBase"""
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def http_client(self):
|
|
13
|
+
"""Create a basic HttpClientBase instance for testing"""
|
|
14
|
+
return HttpClientBase(
|
|
15
|
+
base_url="https://example.com",
|
|
16
|
+
headers={"Content-Type": "application/json"},
|
|
17
|
+
auto_retry=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def test_connection_reset_error_handling(self, http_client):
|
|
21
|
+
"""Test that ConnectionResetError is properly caught and converted to NetworkError"""
|
|
22
|
+
with patch.object(http_client.session, 'request') as mock_request:
|
|
23
|
+
# Simulate a ConnectionResetError wrapped in requests.exceptions.ConnectionError
|
|
24
|
+
connection_reset = ConnectionResetError(104, 'Connection reset by peer')
|
|
25
|
+
mock_request.side_effect = requests.exceptions.ConnectionError(
|
|
26
|
+
connection_reset
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
30
|
+
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
31
|
+
|
|
32
|
+
# Verify the NetworkError is properly created
|
|
33
|
+
error = exc_info.value
|
|
34
|
+
assert error.can_retry is True
|
|
35
|
+
assert "Network connection error" in error.message
|
|
36
|
+
assert error.detail["errorType"] == "ConnectionError"
|
|
37
|
+
|
|
38
|
+
def test_connection_error_is_retriable(self, http_client):
|
|
39
|
+
"""Test that connection errors are marked as retriable"""
|
|
40
|
+
with patch.object(http_client.session, 'request') as mock_request:
|
|
41
|
+
mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
|
42
|
+
|
|
43
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
44
|
+
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
45
|
+
|
|
46
|
+
error = exc_info.value
|
|
47
|
+
assert error.can_retry is True
|
|
48
|
+
assert hasattr(error, 'retry_delay')
|
|
49
|
+
assert error.retry_delay == 4 # Default retry delay
|
|
50
|
+
|
|
51
|
+
def test_timeout_error_is_retriable(self, http_client):
|
|
52
|
+
"""Test that timeout errors are marked as retriable"""
|
|
53
|
+
with patch.object(http_client.session, 'request') as mock_request:
|
|
54
|
+
mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
|
|
55
|
+
|
|
56
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
57
|
+
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
58
|
+
|
|
59
|
+
error = exc_info.value
|
|
60
|
+
assert error.can_retry is True
|
|
61
|
+
assert hasattr(error, 'retry_delay')
|
|
62
|
+
|
|
63
|
+
def test_retry_uses_exception_delay(self, http_client):
|
|
64
|
+
"""Test that retry mechanism uses the delay from the exception"""
|
|
65
|
+
call_count = 0
|
|
66
|
+
|
|
67
|
+
def side_effect(*args, **kwargs):
|
|
68
|
+
nonlocal call_count
|
|
69
|
+
call_count += 1
|
|
70
|
+
if call_count < 3: # Fail first 2 times
|
|
71
|
+
raise requests.exceptions.ConnectionError("Connection failed")
|
|
72
|
+
# Success on 3rd attempt
|
|
73
|
+
mock_response = Mock()
|
|
74
|
+
mock_response.status_code = 200
|
|
75
|
+
return mock_response
|
|
76
|
+
|
|
77
|
+
with patch.object(http_client.session, 'request', side_effect=side_effect):
|
|
78
|
+
with patch('time.sleep') as mock_sleep:
|
|
79
|
+
result = http_client._req_with_retry(
|
|
80
|
+
"GET", "https://example.com/test", "Test Step", retries=2
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
assert result.status_code == 200
|
|
84
|
+
# Should have slept twice (after 1st and 2nd failures)
|
|
85
|
+
assert mock_sleep.call_count == 2
|
|
86
|
+
# Should use default delay of 4 seconds
|
|
87
|
+
mock_sleep.assert_called_with(4)
|
|
88
|
+
|
|
89
|
+
def test_retry_exhausted_marks_non_retriable(self, http_client):
|
|
90
|
+
"""Test that after exhausting retries, error is marked as non-retriable"""
|
|
91
|
+
with patch.object(http_client.session, 'request') as mock_request:
|
|
92
|
+
mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
|
93
|
+
|
|
94
|
+
with pytest.raises(NetworkError) as exc_info:
|
|
95
|
+
http_client._req_with_retry(
|
|
96
|
+
"GET", "https://example.com/test", "Test Step", retries=1
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
error = exc_info.value
|
|
100
|
+
# After retries are exhausted, can_retry should be False
|
|
101
|
+
assert error.can_retry is False
|
|
102
|
+
|
|
103
|
+
def test_no_retry_when_auto_retry_false(self, http_client):
|
|
104
|
+
"""Test that retry doesn't happen when auto_retry is False"""
|
|
105
|
+
http_client.auto_retry = False
|
|
106
|
+
call_count = 0
|
|
107
|
+
|
|
108
|
+
def side_effect(*args, **kwargs):
|
|
109
|
+
nonlocal call_count
|
|
110
|
+
call_count += 1
|
|
111
|
+
raise requests.exceptions.ConnectionError("Connection failed")
|
|
112
|
+
|
|
113
|
+
with patch.object(http_client.session, 'request', side_effect=side_effect):
|
|
114
|
+
with pytest.raises(NetworkError):
|
|
115
|
+
http_client._req_with_retry(
|
|
116
|
+
"GET", "https://example.com/test", "Test Step", retries=2
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Should only be called once (no retries)
|
|
120
|
+
assert call_count == 1
|
|
121
|
+
|
|
122
|
+
def test_successful_request_no_error(self, http_client):
|
|
123
|
+
"""Test that successful requests don't raise errors"""
|
|
124
|
+
with patch.object(http_client.session, 'request') as mock_request:
|
|
125
|
+
mock_response = Mock()
|
|
126
|
+
mock_response.status_code = 200
|
|
127
|
+
mock_request.return_value = mock_response
|
|
128
|
+
|
|
129
|
+
result = http_client._req("GET", "https://example.com/test", "Test Step")
|
|
130
|
+
|
|
131
|
+
assert result.status_code == 200
|
|
132
|
+
|
|
133
|
+
def test_custom_retry_delay(self):
|
|
134
|
+
"""Test that custom retry delay can be set on exception"""
|
|
135
|
+
exception = CertApiException("Test error")
|
|
136
|
+
exception.retry_delay = 10
|
|
137
|
+
exception.can_retry = True
|
|
138
|
+
|
|
139
|
+
assert exception.retry_delay == 10
|
|
140
|
+
assert exception.can_retry is True
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from time import timezone
|
|
2
|
+
import pytest
|
|
3
|
+
import os
|
|
4
|
+
import psycopg2 # Added for PostgreSQL database creation
|
|
5
|
+
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT # Added for PostgreSQL database creation
|
|
6
|
+
|
|
7
|
+
from certapi import Key, Certificate
|
|
8
|
+
from certapi.crypto.crypto import cert_to_pem, certs_to_pem
|
|
9
|
+
from certapi.keystore import SqliteKeyStore, FileSystemKeyStore, PostgresKeyStore
|
|
10
|
+
from typing import List, Tuple, Union
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from certapi import KeyStore, Certificate, Key
|
|
13
|
+
|
|
14
|
+
from cryptography import x509
|
|
15
|
+
from cryptography.hazmat.primitives import hashes
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def ca_key():
|
|
21
|
+
return Key.generate("ecdsa")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(params=["sqlite", "filesystem", "postgresql"])
|
|
25
|
+
def keystore(request, tmp_path):
|
|
26
|
+
if request.param == "sqlite":
|
|
27
|
+
db_path = tmp_path / "test.db"
|
|
28
|
+
store = SqliteKeyStore(db_path=str(db_path))
|
|
29
|
+
yield store
|
|
30
|
+
# Clean up after test
|
|
31
|
+
if os.path.exists(db_path):
|
|
32
|
+
os.remove(db_path)
|
|
33
|
+
elif request.param == "filesystem":
|
|
34
|
+
base_dir = tmp_path / "keystore_fs"
|
|
35
|
+
store = FileSystemKeyStore(base_dir=str(base_dir))
|
|
36
|
+
yield store
|
|
37
|
+
# Clean up after test
|
|
38
|
+
import shutil
|
|
39
|
+
|
|
40
|
+
if os.path.exists(base_dir):
|
|
41
|
+
shutil.rmtree(base_dir)
|
|
42
|
+
elif request.param == "postgresql":
|
|
43
|
+
db_url = "postgresql://localhost/test_db"
|
|
44
|
+
try:
|
|
45
|
+
conn_no_db = psycopg2.connect("postgresql://localhost/postgres")
|
|
46
|
+
conn_no_db.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
|
47
|
+
cur_no_db = conn_no_db.cursor()
|
|
48
|
+
cur_no_db.execute("SELECT 1 FROM pg_database WHERE datname = 'test_db'")
|
|
49
|
+
exists = cur_no_db.fetchone()
|
|
50
|
+
if not exists:
|
|
51
|
+
cur_no_db.execute("CREATE DATABASE test_db")
|
|
52
|
+
cur_no_db.close()
|
|
53
|
+
conn_no_db.close()
|
|
54
|
+
except psycopg2.OperationalError as e:
|
|
55
|
+
pytest.skip(f"Could not connect to PostgreSQL to create test_db: {e}")
|
|
56
|
+
|
|
57
|
+
store = PostgresKeyStore(db_url=db_url)
|
|
58
|
+
yield store
|
|
59
|
+
# Clean up after test: drop tables
|
|
60
|
+
with store.get_connection() as conn:
|
|
61
|
+
cur = conn.cursor()
|
|
62
|
+
cur.execute("DROP TABLE IF EXISTS ssl_wildcards;")
|
|
63
|
+
cur.execute("DROP TABLE IF EXISTS ssl_domains;")
|
|
64
|
+
cur.execute("DROP TABLE IF EXISTS certificates;")
|
|
65
|
+
cur.execute("DROP TABLE IF EXISTS private_keys;")
|
|
66
|
+
conn.commit()
|
|
67
|
+
cur.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_save_and_find_key(keystore: KeyStore):
|
|
71
|
+
key = Key.generate("rsa")
|
|
72
|
+
key_id = keystore.save_key(key, "test_key")
|
|
73
|
+
assert key_id is not None
|
|
74
|
+
|
|
75
|
+
found_key = keystore.find_key_by_name("test_key")
|
|
76
|
+
assert found_key is not None
|
|
77
|
+
assert found_key.to_pem() == key.to_pem()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_save_and_find_cert(keystore: KeyStore, ca_key: Key):
|
|
81
|
+
key = Key.generate("rsa")
|
|
82
|
+
key_id = keystore.save_key(key, "cert_key")
|
|
83
|
+
|
|
84
|
+
csr = key.create_csr(domain="example.com", alt_names=["example.com"])
|
|
85
|
+
|
|
86
|
+
cert = sign_csr(csr, ca_key, 7)
|
|
87
|
+
|
|
88
|
+
cert_id = keystore.save_cert(key_id, cert, ["example.com"], "test_cert")
|
|
89
|
+
assert cert_id is not None
|
|
90
|
+
|
|
91
|
+
found_cert_tuple = keystore.find_key_and_cert_by_domain("example.com")
|
|
92
|
+
assert found_cert_tuple is not None
|
|
93
|
+
found_id, found_key, found_certs = found_cert_tuple
|
|
94
|
+
if not isinstance(keystore, FileSystemKeyStore):
|
|
95
|
+
assert found_id == cert_id
|
|
96
|
+
assert found_key.to_pem() == key.to_pem()
|
|
97
|
+
assert len(found_certs) == 1
|
|
98
|
+
assert cert_to_pem(found_certs[0]) == cert_to_pem(cert)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_get_non_existent_key(keystore: KeyStore):
|
|
102
|
+
found_key = keystore.find_key_by_name("non_existent_key")
|
|
103
|
+
assert found_key is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_get_non_existent_cert(keystore: KeyStore):
|
|
107
|
+
found_cert = keystore.find_key_and_cert_by_domain("nonexistent.com")
|
|
108
|
+
assert found_cert is None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_save_key_with_int_id(keystore: KeyStore):
|
|
112
|
+
key = Key.generate("ecdsa")
|
|
113
|
+
key_id = keystore.save_key(key, 123)
|
|
114
|
+
assert key_id == 123 or key_id == "123"
|
|
115
|
+
|
|
116
|
+
found_key = keystore.find_key_by_id(123)
|
|
117
|
+
assert found_key is not None
|
|
118
|
+
assert found_key.to_pem() == key.to_pem()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_save_cert_with_list_of_certs(keystore, ca_key: Key):
|
|
122
|
+
key = Key.generate("rsa")
|
|
123
|
+
key_id = keystore.save_key(key, "cert_list_key")
|
|
124
|
+
|
|
125
|
+
csr1 = key.create_csr(domain="cert1.example.com", alt_names=["cert1.example.com"])
|
|
126
|
+
cert1 = sign_csr(csr1, ca_key, 1)
|
|
127
|
+
|
|
128
|
+
csr2 = key.create_csr(domain="cert2.example.com", alt_names=["cert2.example.com"])
|
|
129
|
+
cert2 = sign_csr(csr2, ca_key, 1)
|
|
130
|
+
|
|
131
|
+
certs_list = [cert1, cert2]
|
|
132
|
+
cert_id = keystore.save_cert(key_id, certs_list, ["cert1.example.com", "cert2.example.com"], "test_certs_list")
|
|
133
|
+
assert cert_id is not None
|
|
134
|
+
|
|
135
|
+
found_cert_tuple = keystore.find_key_and_cert_by_domain("cert1.example.com")
|
|
136
|
+
assert found_cert_tuple is not None
|
|
137
|
+
found_id, found_key, found_certs = found_cert_tuple
|
|
138
|
+
if not isinstance(keystore, FileSystemKeyStore):
|
|
139
|
+
assert found_id == cert_id
|
|
140
|
+
assert found_key.to_pem() == key.to_pem()
|
|
141
|
+
assert len(found_certs) == 2
|
|
142
|
+
assert cert_to_pem(found_certs[0]) == cert_to_pem(cert1)
|
|
143
|
+
assert cert_to_pem(found_certs[1]) == cert_to_pem(cert2)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_get_cert_by_id(keystore: KeyStore, ca_key: Key):
|
|
147
|
+
key = Key.generate("rsa")
|
|
148
|
+
domain = "example.com"
|
|
149
|
+
|
|
150
|
+
key_id = keystore.save_key(key, domain)
|
|
151
|
+
|
|
152
|
+
csr = key.create_csr(domain=domain, alt_names=[domain])
|
|
153
|
+
cert = sign_csr(csr, ca_key, 1)
|
|
154
|
+
|
|
155
|
+
cert_id = keystore.save_cert(key_id, cert, [domain], domain)
|
|
156
|
+
assert cert_id is not None
|
|
157
|
+
|
|
158
|
+
found_cert_tuple = keystore.find_key_and_cert_by_cert_id(cert_id)
|
|
159
|
+
assert found_cert_tuple is not None
|
|
160
|
+
found_key, found_certs = found_cert_tuple
|
|
161
|
+
assert found_key.to_pem() == key.to_pem()
|
|
162
|
+
assert len(found_certs) == 1
|
|
163
|
+
assert cert_to_pem(found_certs[0]) == cert_to_pem(cert)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sign_csr(csr: x509.CertificateSigningRequest, issuer_key: Key, days_valid=365) -> Certificate:
|
|
167
|
+
now = datetime.now(UTC)
|
|
168
|
+
builder = (
|
|
169
|
+
x509.CertificateBuilder()
|
|
170
|
+
.subject_name(csr.subject)
|
|
171
|
+
.issuer_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "certapi.pytest.com")]))
|
|
172
|
+
.public_key(csr.public_key())
|
|
173
|
+
.serial_number(x509.random_serial_number())
|
|
174
|
+
.not_valid_before(now)
|
|
175
|
+
.not_valid_after(now+ timedelta(days=days_valid))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Optionally copy extensions from CSR
|
|
179
|
+
for ext in csr.extensions:
|
|
180
|
+
builder = builder.add_extension(ext.value, ext.critical)
|
|
181
|
+
return issuer_key.sign_csr(builder)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-0.5.0 → certapi-0.6.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|