certapi 0.4.8__tar.gz → 0.5.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 (59) hide show
  1. {certapi-0.4.8/src/certapi.egg-info → certapi-0.5.1}/PKG-INFO +15 -2
  2. {certapi-0.4.8 → certapi-0.5.1}/README.md +3 -1
  3. {certapi-0.4.8 → certapi-0.5.1}/setup.py +1 -1
  4. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/__init__.py +1 -1
  5. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/Acme.py +4 -4
  6. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/http.py +2 -2
  7. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/ChallengeSolver.py +1 -1
  8. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/client/cert_manager_client.py +1 -1
  9. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/crypto/crypto.py +0 -1
  10. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/manager/acme_cert_manager.py +9 -5
  11. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/server/api.py +1 -1
  12. {certapi-0.4.8 → certapi-0.5.1/src/certapi.egg-info}/PKG-INFO +15 -2
  13. {certapi-0.4.8 → certapi-0.5.1}/src/certapi.egg-info/SOURCES.txt +4 -1
  14. certapi-0.5.1/tests/test_cert_issuer_generic.py +202 -0
  15. certapi-0.5.1/tests/test_certs_with_key_types.py +41 -0
  16. certapi-0.5.1/tests/test_keystores.py +179 -0
  17. {certapi-0.4.8 → certapi-0.5.1}/MANIFEST.in +0 -0
  18. {certapi-0.4.8 → certapi-0.5.1}/pyproject.toml +0 -0
  19. {certapi-0.4.8 → certapi-0.5.1}/setup.cfg +0 -0
  20. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/AcmeError.py +0 -0
  21. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/Challenge.py +0 -0
  22. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/Order.py +0 -0
  23. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/acme/__init__.py +0 -0
  24. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  25. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  26. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/__init__.py +0 -0
  27. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  28. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  29. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  30. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  31. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  32. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  33. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  34. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/client/__init__.py +0 -0
  35. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/crypto/__init__.py +0 -0
  36. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/crypto/crypto_classes.py +0 -0
  37. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/errors.py +0 -0
  38. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/http/HttpClientBase.py +0 -0
  39. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/http/__init__.py +0 -0
  40. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/http/types.py +0 -0
  41. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  42. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  43. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/issuers/__init__.py +0 -0
  44. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
  45. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  46. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/KeyStore.py +0 -0
  47. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  48. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  49. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  50. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/keystore/__init__.py +0 -0
  51. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/manager/__init__.py +0 -0
  52. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/server/__init__.py +0 -0
  53. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/server/cert_api.py +0 -0
  54. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/server/key_api.py +0 -0
  55. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/util.py +0 -0
  56. {certapi-0.4.8 → certapi-0.5.1}/src/certapi/utils.py +0 -0
  57. {certapi-0.4.8 → certapi-0.5.1}/src/certapi.egg-info/dependency_links.txt +0 -0
  58. {certapi-0.4.8 → certapi-0.5.1}/src/certapi.egg-info/requires.txt +0 -0
  59. {certapi-0.4.8 → certapi-0.5.1}/src/certapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 0.4.8
3
+ Version: 0.5.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
@@ -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
 
@@ -18,6 +29,8 @@ This is to be used as a base library for building other tools, or to integrate C
18
29
 
19
30
  > ⚠️ Warning: This project is in beta. Please stay tuned for the LTS `v1.0.0` release.
20
31
 
32
+ For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
33
+
21
34
  ## Installation
22
35
 
23
36
  You can install CertApi using pip
@@ -5,6 +5,8 @@ This is to be used as a base library for building other tools, or to integrate C
5
5
 
6
6
  > ⚠️ Warning: This project is in beta. Please stay tuned for the LTS `v1.0.0` release.
7
7
 
8
+ For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
9
+
8
10
  ## Installation
9
11
 
10
12
  You can install CertApi using pip
@@ -47,4 +49,4 @@ except CertApiException as e:
47
49
  ## Example: Use High Leve API
48
50
 
49
51
  ```
50
- ```
52
+ ```
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.4.8",
5
+ version="0.5.1",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -1,4 +1,4 @@
1
- from .acme.Acme import Acme, Order, AcmeNetworkError, AcmeHttpError,AcmeError, Challenge
1
+ from .acme.Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, AcmeError, Challenge
2
2
  from .manager.acme_cert_manager import AcmeCertManager
3
3
  from .http.types import CertificateResponse, IssuedCert
4
4
  from .errors import CertApiException
@@ -93,9 +93,9 @@ class Acme:
93
93
  return response
94
94
 
95
95
  def _signed_req(
96
- self, url, payload: Union[str, dict, list, bytes, None] = None, depth=0, step="Acme Request", throw=True
96
+ self, url, req_payload: Union[str, dict, list, bytes, None] = None, depth=0, step="Acme Request", throw=True
97
97
  ) -> requests.Response:
98
- payload64 = b64_encode(payload) if payload is not None else b""
98
+ payload64 = b64_encode(req_payload) if req_payload is not None else b""
99
99
 
100
100
  protected = {
101
101
  "url": url,
@@ -118,9 +118,9 @@ class Acme:
118
118
 
119
119
  response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
120
120
  except AcmeError as e:
121
- if e.can_retry and depth <= 0:
121
+ if e.can_retry and depth <= 1:
122
122
  time.sleep(2)
123
- return self._signed_req(url, payload, depth + 1, step, throw)
123
+ return self._signed_req(url, req_payload, depth + 1, step, throw)
124
124
  else:
125
125
  raise e
126
126
 
@@ -22,8 +22,8 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
22
22
  step,
23
23
  )
24
24
  if 199 <= res.status_code > 299:
25
- if(json):
26
- print("Request:",j.dumps(json))
25
+ if json:
26
+ print("Request:", j.dumps(json))
27
27
  [print(x, y) for (x, y) in res.headers.items()]
28
28
  print("Response:", res.text)
29
29
  json_data = None
@@ -6,7 +6,7 @@ from typing import Literal
6
6
 
7
7
  class ChallengeSolver(ABC):
8
8
  """
9
- Abstract base class for a challenge store.
9
+ Abstract base class for a challenge solver.
10
10
  """
11
11
 
12
12
  @abstractmethod
@@ -27,7 +27,7 @@ class CertManagerClient:
27
27
  def obtain_certificate(
28
28
  self,
29
29
  hostnames: Union[str, List[str]],
30
- key_type: str = "rsa",
30
+ key_type: str = "ecdsa",
31
31
  expiry_days: int = 90,
32
32
  country: Optional[str] = None,
33
33
  state: Optional[str] = None,
@@ -181,7 +181,6 @@ def digest_sha256(data: bytes) -> bytes:
181
181
  def get_csr_hostnames(csr: x509.CertificateSigningRequest):
182
182
 
183
183
  domains = []
184
-
185
184
  common_names = [attr.value for attr in csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)]
186
185
  if common_names:
187
186
  cn = common_names[0]
@@ -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
@@ -36,7 +37,7 @@ class AcmeCertManager:
36
37
  if not hostnames:
37
38
  raise ValueError("CSR does not contain any hostnames.")
38
39
 
39
- # Find a challenge store that supports all hostnames in the CSR
40
+ # Find a challenge solver that supports all hostnames in the CSR
40
41
  selected_challenge_solver = None
41
42
  for store in self.challenge_solvers:
42
43
  if all(store.supports_domain(h) for h in hostnames):
@@ -44,7 +45,7 @@ class AcmeCertManager:
44
45
  break
45
46
 
46
47
  if selected_challenge_solver is None:
47
- raise ValueError(f"No challenge store found that supports all domains: {hostnames}")
48
+ raise ValueError(f"No challenge solver found that supports all domains: {hostnames}")
48
49
 
49
50
  fullchain_cert = self.cert_issuer.sign_csr(csr, challenge_solver=selected_challenge_solver)
50
51
  if fullchain_cert:
@@ -76,12 +77,15 @@ 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
- existing[h] = result # Store the certificate list
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 = []
84
- # Group missing hosts by the challenge store that supports them
88
+ # Group missing hosts by the challenge solver that supports them
85
89
  domains_by_store: Dict[ChallengeSolver, List[str]] = {}
86
90
  for host in missing:
87
91
  found_store = None
@@ -38,7 +38,7 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager):
38
38
  obtain_parser.add_argument(
39
39
  "hostname", type=str, action="append", required=True, help="List of hostnames for the certificate"
40
40
  )
41
- obtain_parser.add_argument("key_type", type=str, default="rsa", help="Type of key (rsa or ecdsa)")
41
+ obtain_parser.add_argument("key_type", type=str, default="ecdsa", help="Type of key (rsa or ecdsa)")
42
42
  obtain_parser.add_argument("expiry_days", type=int, default=90, help="Number of days until certificate expiry")
43
43
  obtain_parser.add_argument("country", type=str, help="Country name")
44
44
  obtain_parser.add_argument("state", type=str, help="State or province name")
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 0.4.8
3
+ Version: 0.5.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
@@ -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
 
@@ -18,6 +29,8 @@ This is to be used as a base library for building other tools, or to integrate C
18
29
 
19
30
  > ⚠️ Warning: This project is in beta. Please stay tuned for the LTS `v1.0.0` release.
20
31
 
32
+ For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
33
+
21
34
  ## Installation
22
35
 
23
36
  You can install CertApi using pip
@@ -51,4 +51,7 @@ 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_keystores.py
@@ -0,0 +1,202 @@
1
+ import pytest
2
+ from datetime import 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(timezone.utc)
62
+ # Ensure certificate validity dates are timezone-aware UTC for comparison
63
+ cert_not_valid_before_utc = cert.not_valid_before.astimezone(timezone.utc)
64
+ cert_not_valid_after_utc = cert.not_valid_after.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,179 @@
1
+ import pytest
2
+ import os
3
+ import psycopg2 # Added for PostgreSQL database creation
4
+ from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT # Added for PostgreSQL database creation
5
+
6
+ from certapi import Key, Certificate
7
+ from certapi.crypto.crypto import cert_to_pem, certs_to_pem
8
+ from certapi.keystore import SqliteKeyStore, FileSystemKeyStore, PostgresKeyStore
9
+ from typing import List, Tuple, Union
10
+ from datetime import datetime, timedelta
11
+ from certapi import KeyStore, Certificate, Key
12
+
13
+ from cryptography import x509
14
+ from cryptography.hazmat.primitives import hashes
15
+ from datetime import datetime, timedelta
16
+
17
+
18
+ @pytest.fixture(scope="session")
19
+ def ca_key():
20
+ return Key.generate("ecdsa")
21
+
22
+
23
+ @pytest.fixture(params=["sqlite", "filesystem", "postgresql"])
24
+ def keystore(request, tmp_path):
25
+ if request.param == "sqlite":
26
+ db_path = tmp_path / "test.db"
27
+ store = SqliteKeyStore(db_path=str(db_path))
28
+ yield store
29
+ # Clean up after test
30
+ if os.path.exists(db_path):
31
+ os.remove(db_path)
32
+ elif request.param == "filesystem":
33
+ base_dir = tmp_path / "keystore_fs"
34
+ store = FileSystemKeyStore(base_dir=str(base_dir))
35
+ yield store
36
+ # Clean up after test
37
+ import shutil
38
+
39
+ if os.path.exists(base_dir):
40
+ shutil.rmtree(base_dir)
41
+ elif request.param == "postgresql":
42
+ db_url = "postgresql://localhost/test_db"
43
+ try:
44
+ conn_no_db = psycopg2.connect("postgresql://localhost/postgres")
45
+ conn_no_db.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
46
+ cur_no_db = conn_no_db.cursor()
47
+ cur_no_db.execute("SELECT 1 FROM pg_database WHERE datname = 'test_db'")
48
+ exists = cur_no_db.fetchone()
49
+ if not exists:
50
+ cur_no_db.execute("CREATE DATABASE test_db")
51
+ cur_no_db.close()
52
+ conn_no_db.close()
53
+ except psycopg2.OperationalError as e:
54
+ pytest.skip(f"Could not connect to PostgreSQL to create test_db: {e}")
55
+
56
+ store = PostgresKeyStore(db_url=db_url)
57
+ yield store
58
+ # Clean up after test: drop tables
59
+ with store.get_connection() as conn:
60
+ cur = conn.cursor()
61
+ cur.execute("DROP TABLE IF EXISTS ssl_wildcards;")
62
+ cur.execute("DROP TABLE IF EXISTS ssl_domains;")
63
+ cur.execute("DROP TABLE IF EXISTS certificates;")
64
+ cur.execute("DROP TABLE IF EXISTS private_keys;")
65
+ conn.commit()
66
+ cur.close()
67
+
68
+
69
+ def test_save_and_find_key(keystore: KeyStore):
70
+ key = Key.generate("rsa")
71
+ key_id = keystore.save_key(key, "test_key")
72
+ assert key_id is not None
73
+
74
+ found_key = keystore.find_key_by_name("test_key")
75
+ assert found_key is not None
76
+ assert found_key.to_pem() == key.to_pem()
77
+
78
+
79
+ def test_save_and_find_cert(keystore: KeyStore, ca_key: Key):
80
+ key = Key.generate("rsa")
81
+ key_id = keystore.save_key(key, "cert_key")
82
+
83
+ csr = key.create_csr(domain="example.com", alt_names=["example.com"])
84
+
85
+ cert = sign_csr(csr, ca_key, 7)
86
+
87
+ cert_id = keystore.save_cert(key_id, cert, ["example.com"], "test_cert")
88
+ assert cert_id is not None
89
+
90
+ found_cert_tuple = keystore.find_key_and_cert_by_domain("example.com")
91
+ assert found_cert_tuple is not None
92
+ found_id, found_key, found_certs = found_cert_tuple
93
+ if not isinstance(keystore, FileSystemKeyStore):
94
+ assert found_id == cert_id
95
+ assert found_key.to_pem() == key.to_pem()
96
+ assert len(found_certs) == 1
97
+ assert cert_to_pem(found_certs[0]) == cert_to_pem(cert)
98
+
99
+
100
+ def test_get_non_existent_key(keystore: KeyStore):
101
+ found_key = keystore.find_key_by_name("non_existent_key")
102
+ assert found_key is None
103
+
104
+
105
+ def test_get_non_existent_cert(keystore: KeyStore):
106
+ found_cert = keystore.find_key_and_cert_by_domain("nonexistent.com")
107
+ assert found_cert is None
108
+
109
+
110
+ def test_save_key_with_int_id(keystore: KeyStore):
111
+ key = Key.generate("ecdsa")
112
+ key_id = keystore.save_key(key, 123)
113
+ assert key_id == 123 or key_id == "123"
114
+
115
+ found_key = keystore.find_key_by_id(123)
116
+ assert found_key is not None
117
+ assert found_key.to_pem() == key.to_pem()
118
+
119
+
120
+ def test_save_cert_with_list_of_certs(keystore, ca_key: Key):
121
+ key = Key.generate("rsa")
122
+ key_id = keystore.save_key(key, "cert_list_key")
123
+
124
+ csr1 = key.create_csr(domain="cert1.example.com", alt_names=["cert1.example.com"])
125
+ cert1 = sign_csr(csr1, ca_key, 1)
126
+
127
+ csr2 = key.create_csr(domain="cert2.example.com", alt_names=["cert2.example.com"])
128
+ cert2 = sign_csr(csr2, ca_key, 1)
129
+
130
+ certs_list = [cert1, cert2]
131
+ cert_id = keystore.save_cert(key_id, certs_list, ["cert1.example.com", "cert2.example.com"], "test_certs_list")
132
+ assert cert_id is not None
133
+
134
+ found_cert_tuple = keystore.find_key_and_cert_by_domain("cert1.example.com")
135
+ assert found_cert_tuple is not None
136
+ found_id, found_key, found_certs = found_cert_tuple
137
+ if not isinstance(keystore, FileSystemKeyStore):
138
+ assert found_id == cert_id
139
+ assert found_key.to_pem() == key.to_pem()
140
+ assert len(found_certs) == 2
141
+ assert cert_to_pem(found_certs[0]) == cert_to_pem(cert1)
142
+ assert cert_to_pem(found_certs[1]) == cert_to_pem(cert2)
143
+
144
+
145
+ def test_get_cert_by_id(keystore: KeyStore, ca_key: Key):
146
+ key = Key.generate("rsa")
147
+ domain = "example.com"
148
+
149
+ key_id = keystore.save_key(key, domain)
150
+
151
+ csr = key.create_csr(domain=domain, alt_names=[domain])
152
+ cert = sign_csr(csr, ca_key, 1)
153
+
154
+ cert_id = keystore.save_cert(key_id, cert, [domain], domain)
155
+ assert cert_id is not None
156
+
157
+ found_cert_tuple = keystore.find_key_and_cert_by_cert_id(cert_id)
158
+ assert found_cert_tuple is not None
159
+ found_key, found_certs = found_cert_tuple
160
+ assert found_key.to_pem() == key.to_pem()
161
+ assert len(found_certs) == 1
162
+ assert cert_to_pem(found_certs[0]) == cert_to_pem(cert)
163
+
164
+
165
+ def sign_csr(csr: x509.CertificateSigningRequest, issuer_key: Key, days_valid=365) -> Certificate:
166
+ builder = (
167
+ x509.CertificateBuilder()
168
+ .subject_name(csr.subject)
169
+ .issuer_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "certapi.pytest.com")]))
170
+ .public_key(csr.public_key())
171
+ .serial_number(x509.random_serial_number())
172
+ .not_valid_before(datetime.utcnow())
173
+ .not_valid_after(datetime.utcnow() + timedelta(days=days_valid))
174
+ )
175
+
176
+ # Optionally copy extensions from CSR
177
+ for ext in csr.extensions:
178
+ builder = builder.add_extension(ext.value, ext.critical)
179
+ return issuer_key.sign_csr(builder)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes