cert-utils 1.2.0__py3-none-any.whl

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 (79) hide show
  1. cert_utils/__init__.py +8 -0
  2. cert_utils/compat/__init__.py +0 -0
  3. cert_utils/compat/cryptography.py +172 -0
  4. cert_utils/compat/openssl.py +860 -0
  5. cert_utils/compat/pyopenssl.py +207 -0
  6. cert_utils/compat/signatures.py +114 -0
  7. cert_utils/conditionals.py +121 -0
  8. cert_utils/convert.py +307 -0
  9. cert_utils/core.py +3075 -0
  10. cert_utils/errors.py +54 -0
  11. cert_utils/letsencrypt-certs/deprecated/e5-cross.pem +26 -0
  12. cert_utils/letsencrypt-certs/deprecated/e5.pem +17 -0
  13. cert_utils/letsencrypt-certs/deprecated/e6-cross.pem +26 -0
  14. cert_utils/letsencrypt-certs/deprecated/e6.pem +17 -0
  15. cert_utils/letsencrypt-certs/deprecated/e7-cross.pem +26 -0
  16. cert_utils/letsencrypt-certs/deprecated/e7.pem +17 -0
  17. cert_utils/letsencrypt-certs/deprecated/e8-cross.pem +26 -0
  18. cert_utils/letsencrypt-certs/deprecated/e8.pem +17 -0
  19. cert_utils/letsencrypt-certs/deprecated/e9-cross.pem +26 -0
  20. cert_utils/letsencrypt-certs/deprecated/e9.pem +17 -0
  21. cert_utils/letsencrypt-certs/deprecated/fakeleintermediatex1.pem +28 -0
  22. cert_utils/letsencrypt-certs/deprecated/fakelerootx1.pem +29 -0
  23. cert_utils/letsencrypt-certs/deprecated/isrg-root-ocsp-x1.pem +28 -0
  24. cert_utils/letsencrypt-certs/deprecated/isrg-root-x1-cross-signed.pem +31 -0
  25. cert_utils/letsencrypt-certs/deprecated/isrg-root-x2-cross-signed.pem +26 -0
  26. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-e1.pem +17 -0
  27. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-e2.pem +17 -0
  28. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r3-cross-signed.pem +26 -0
  29. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r3.pem +30 -0
  30. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r4-cross-signed.pem +26 -0
  31. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r4.pem +30 -0
  32. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x1-cross-signed.pem +27 -0
  33. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x2-cross-signed.pem +27 -0
  34. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x3-cross-signed.pem +27 -0
  35. cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x4-cross-signed.pem +27 -0
  36. cert_utils/letsencrypt-certs/deprecated/letsencrypt-stg-root-x2-signed-by-x1.pem +27 -0
  37. cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx1.pem +32 -0
  38. cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx2.pem +32 -0
  39. cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx3.pem +32 -0
  40. cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx4.pem +32 -0
  41. cert_utils/letsencrypt-certs/deprecated/r10.pem +29 -0
  42. cert_utils/letsencrypt-certs/deprecated/r11.pem +29 -0
  43. cert_utils/letsencrypt-certs/deprecated/r12.pem +29 -0
  44. cert_utils/letsencrypt-certs/deprecated/r13.pem +29 -0
  45. cert_utils/letsencrypt-certs/deprecated/r14.pem +29 -0
  46. cert_utils/letsencrypt-certs/deprecated/trustid-x3-root.p7c +0 -0
  47. cert_utils/letsencrypt-certs/deprecated/trustid-x3-root.pem +20 -0
  48. cert_utils/letsencrypt-certs/int-ye1.pem +16 -0
  49. cert_utils/letsencrypt-certs/int-ye2.pem +16 -0
  50. cert_utils/letsencrypt-certs/int-ye3.pem +16 -0
  51. cert_utils/letsencrypt-certs/int-yr1.pem +28 -0
  52. cert_utils/letsencrypt-certs/int-yr2.pem +28 -0
  53. cert_utils/letsencrypt-certs/int-yr3.pem +28 -0
  54. cert_utils/letsencrypt-certs/isrg-root-x2.pem +14 -0
  55. cert_utils/letsencrypt-certs/isrgrootx1.pem +31 -0
  56. cert_utils/letsencrypt-certs/isrgrootx1.pkix +0 -0
  57. cert_utils/letsencrypt-certs/letsencrypt-stg-root-x1.pem +32 -0
  58. cert_utils/letsencrypt-certs/letsencrypt-stg-root-x2.pem +15 -0
  59. cert_utils/letsencrypt-certs/root-x2-by-x1.pem +26 -0
  60. cert_utils/letsencrypt-certs/root-ye-by-x2.pem +17 -0
  61. cert_utils/letsencrypt-certs/root-ye.pem +12 -0
  62. cert_utils/letsencrypt-certs/root-yr-by-x1.pem +34 -0
  63. cert_utils/letsencrypt-certs/root-yr.pem +30 -0
  64. cert_utils/letsencrypt_info/__init__.py +6 -0
  65. cert_utils/letsencrypt_info/certificates.py +1074 -0
  66. cert_utils/letsencrypt_info/rate_limits.py +414 -0
  67. cert_utils/letsencrypt_info/types.py +120 -0
  68. cert_utils/model.py +181 -0
  69. cert_utils/network.py +28 -0
  70. cert_utils/py.typed +0 -0
  71. cert_utils/scripts/__init__.py +0 -0
  72. cert_utils/scripts/cert_info.py +332 -0
  73. cert_utils/utils.py +388 -0
  74. cert_utils-1.2.0.dist-info/METADATA +259 -0
  75. cert_utils-1.2.0.dist-info/RECORD +79 -0
  76. cert_utils-1.2.0.dist-info/WHEEL +5 -0
  77. cert_utils-1.2.0.dist-info/entry_points.txt +2 -0
  78. cert_utils-1.2.0.dist-info/licenses/LICENSE.txt +224 -0
  79. cert_utils-1.2.0.dist-info/top_level.txt +1 -0
cert_utils/utils.py ADDED
@@ -0,0 +1,388 @@
1
+ # General Utility Functions
2
+
3
+ # stdlib
4
+ import base64
5
+ import binascii
6
+ import hashlib
7
+ import ipaddress
8
+ import re
9
+ import tempfile
10
+ from typing import Iterable
11
+ from typing import List
12
+ from typing import Tuple
13
+ from typing import Union
14
+
15
+ # pypi
16
+ from typing_extensions import Literal
17
+
18
+ # ==============================================================================
19
+
20
+ _RE_rn = re.compile(r"\r\n")
21
+
22
+
23
+ # https://github.com/certbot/certbot/blob/master/certbot/certbot/crypto_util.py#L482
24
+ #
25
+ # Finds one CERTIFICATE stricttextualmsg according to rfc7468#section-3.
26
+ # Does not validate the base64text - use crypto.load_certificate.
27
+ #
28
+ # NOTE: this functions slightly differently as " *?" was added
29
+ # the first two letsencrypt certificates added a trailing space, which may
30
+ # not be compliant with the specification
31
+ CERT_PEM_REGEX = re.compile(
32
+ """-----BEGIN CERTIFICATE----- *?\r?
33
+ .+?\r?
34
+ -----END CERTIFICATE----- *?\r?
35
+ """,
36
+ re.DOTALL, # DOTALL (/s) because the base64text may include newlines
37
+ )
38
+
39
+
40
+ # technically we could end in a dot (\.?)
41
+ RE_cert_domain = re.compile(
42
+ r"""
43
+ ^
44
+ (?:\*\.)? # allow a leading "*.", to denote wildcards
45
+ (?:
46
+ [A-Z0-9] # the first char MUST be a letter or number
47
+ (?:
48
+ (?:[A-Z0-9-]{0,61} # inner chars can have a dash
49
+ [A-Z0-9] # the last char must be a leter or number
50
+ )? # make this optional, to support 1 letter subdomains
51
+ \.
52
+ )?
53
+ )+ # one or more components
54
+ (?:
55
+ [A-Z]{2,6}
56
+ |
57
+ [A-Z0-9-]{2,}
58
+ (?<!-) # doesn't end with a dash
59
+ )
60
+
61
+ $
62
+ """,
63
+ re.I | re.X,
64
+ )
65
+
66
+
67
+ # https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
68
+
69
+ # IPv6 RegEx
70
+ RE_ipv6 = re.compile(
71
+ r"""
72
+ (
73
+ ([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}| # 1:2:3:4:5:6:7:8
74
+ ([0-9a-fA-F]{1,4}:){1,7}:| # 1:: 1:2:3:4:5:6:7::
75
+ ([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}| # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8
76
+ ([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}| # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8
77
+ ([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}| # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8
78
+ ([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}| # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8
79
+ ([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}| # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8
80
+ [0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})| # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8
81
+ :((:[0-9a-fA-F]{1,4}){1,7}|:)| # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::
82
+ fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}| # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index)
83
+ ::(ffff(:0{1,4}){0,1}:){0,1}
84
+ ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}
85
+ (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])| # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses)
86
+ ([0-9a-fA-F]{1,4}:){1,4}:
87
+ ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}
88
+ (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]) # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address)
89
+ )
90
+ """,
91
+ re.I | re.X,
92
+ )
93
+
94
+
95
+ # IPv4 RegEx
96
+ RE_ipv4 = re.compile(
97
+ r"""
98
+ ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])
99
+ """,
100
+ re.I | re.X,
101
+ )
102
+
103
+
104
+ # (RSA, (4096,))
105
+ # (EC, ("P-384",))
106
+ TECHNOLOGY_RETURN_VALUES = Tuple[str, Tuple[Union[str, int]]]
107
+
108
+
109
+ # ------------------------------------------------------------------------------
110
+
111
+
112
+ def cleanup_pem_text(pem_text: str) -> str:
113
+ """
114
+ * standardizes newlines;
115
+ * removes trailing spaces;
116
+ * ensures a trailing newline.
117
+
118
+ :param pem_text: PEM formatted string
119
+ :type pem_text: str
120
+ :returns: cleaned PEM text
121
+ :rtype: str
122
+ """
123
+ pem_text = _RE_rn.sub("\n", pem_text)
124
+ _pem_text_lines = [i.strip() for i in pem_text.split("\n")]
125
+ _pem_text_lines = [i for i in _pem_text_lines if i]
126
+ pem_text = "\n".join(_pem_text_lines) + "\n"
127
+ return pem_text
128
+
129
+
130
+ def convert_binary_to_hex(input: bytes) -> str:
131
+ """
132
+ the cryptography package surfaces raw binary data
133
+ openssl uses hex encoding, uppercased, with colons
134
+ this function translates the binary to the hex uppercase.
135
+ the colons can be rendered on demand.
136
+
137
+ example: isrg-root-x2-cross-signed.pem's authority_key_identifier
138
+
139
+ binary (from cryptography)
140
+ y\xb4Y\xe6{\xb6\xe5\xe4\x01s\x80\x08\x88\xc8\x1aX\xf6\xe9\x9bn
141
+
142
+ hex (from openssl)
143
+ 79:B4:59:E6:7B:B6:E5:E4:01:73:80:08:88:C8:1A:58:F6:E9:9B:6E
144
+
145
+ via this function:
146
+ 79B459E67BB6E5E40173800888C81A58F6E99B6E
147
+ """
148
+ # input = "y\xb4Y\xe6{\xb6\xe5\xe4\x01s\x80\x08\x88\xc8\x1aX\xf6\xe9\x9bn"
149
+ _as_hex = binascii.b2a_hex(input)
150
+ # _as_hex = "79b459e67bb6e5e40173800888c81a58f6e99b6e"
151
+ _as_hex = _as_hex.upper()
152
+ # _as_hex = "79B459E67BB6E5E40173800888C81A58F6E99B6E"
153
+ _as_hex_str = _as_hex.decode("utf8")
154
+ return _as_hex_str
155
+
156
+
157
+ def curve_to_nist(curve_name: str) -> str:
158
+ if curve_name == "secp256r1":
159
+ return "P-256"
160
+ elif curve_name == "secp384r1":
161
+ return "P-384"
162
+ raise ValueError("Unknown curve: %s" % curve_name)
163
+
164
+
165
+ def domains_from_list(
166
+ domain_names: Iterable[str],
167
+ allow_hostname: bool = True,
168
+ allow_ipv4: bool = False,
169
+ allow_ipv6: bool = False,
170
+ ipv6_require_compressed: bool = False,
171
+ ) -> List[str]:
172
+ """
173
+ Turns a list of strings into a standardized list of domain names.
174
+
175
+ Will raise `ValueError("invalid domain")` if non-conforming elements are encountered.
176
+
177
+ This invokes `validate_domains`, which uses a simple regex to validate each domain in the list.
178
+
179
+ :param domain_names: (required) An iterable list of strings
180
+ :param allow_hostname: bool, default True
181
+ added in 1.0.4
182
+ :param allow_ipv4: bool, default False
183
+ added in 1.0.4
184
+ :param allow_ipv6: bool, default False
185
+ added in 1.0.4
186
+ :param ipv6_require_compressed: bool, default False
187
+ added in 1.0.5
188
+ """
189
+ domain_names = [d for d in [d.strip().lower() for d in domain_names] if d]
190
+ # make the list unique
191
+ domain_names = sorted(list(set(domain_names)))
192
+ # validate the list
193
+ validate_domains(
194
+ domain_names,
195
+ allow_hostname=allow_hostname,
196
+ allow_ipv4=allow_ipv4,
197
+ allow_ipv6=allow_ipv6,
198
+ ipv6_require_compressed=ipv6_require_compressed,
199
+ )
200
+ return domain_names
201
+
202
+
203
+ def domains_from_string(
204
+ text: str,
205
+ allow_hostname: bool = True,
206
+ allow_ipv4: bool = False,
207
+ allow_ipv6: bool = False,
208
+ ipv6_require_compressed: bool = False,
209
+ ) -> List[str]:
210
+ """
211
+ This invokes `domains_from_list` which invokes `validate_domains`, which uses a simple regex to validate each domain in the list.
212
+
213
+ This will raise a `ValueError("invalid domain")` on the first invalid domain
214
+
215
+ :param text: (required) Turns a comma-separated-list of domain names into a list
216
+ :param allow_hostname: bool, default True
217
+ added in 1.0.4
218
+ :param allow_ipv4: bool, default False
219
+ added in 1.0.4
220
+ :param allow_ipv6: bool, default False
221
+ added in 1.0.4
222
+ :param ipv6_require_compressed: bool, default False
223
+ added in 1.0.5
224
+ """
225
+ # generate list
226
+ domain_names = text.split(",")
227
+ return domains_from_list(
228
+ domain_names,
229
+ allow_hostname=allow_hostname,
230
+ allow_ipv4=allow_ipv4,
231
+ allow_ipv6=allow_ipv6,
232
+ ipv6_require_compressed=ipv6_require_compressed,
233
+ )
234
+
235
+
236
+ def hex_with_colons(as_hex: str) -> str:
237
+ # as_hex = '79B459E67BB6E5E40173800888C81A58F6E99B6E'
238
+ _pairs = [as_hex[idx : idx + 2] for idx in range(0, len(as_hex), 2)]
239
+ # _pairs = ['79', 'B4', '59', 'E6', '7B', 'B6', 'E5', 'E4', '01', '73', '80', '08', '88', 'C8', '1A', '58', 'F6', 'E9', '9B', '6E']
240
+ output = ":".join(_pairs)
241
+ # '79:B4:59:E6:7B:B6:E5:E4:01:73:80:08:88:C8:1A:58:F6:E9:9B:6E'
242
+ return output
243
+
244
+
245
+ def identify_san_type(
246
+ san: str,
247
+ ) -> Literal["hostname", "ipv4", "ipv6"]:
248
+ """
249
+ This does a rudimentary check against hostnames (default) and optionally
250
+ ipv4 and ipv6.
251
+
252
+ see also: validate_domains
253
+ added in 1.0.4
254
+ """
255
+ if RE_cert_domain.match(san):
256
+ if RE_ipv4.match(san):
257
+ return "ipv4"
258
+ return "hostname"
259
+ if RE_ipv4.match(san):
260
+ return "ipv4"
261
+ if RE_ipv6.match(san):
262
+ return "ipv6"
263
+ raise ValueError("invalid san: `%s`" % san)
264
+
265
+
266
+ def jose_b64(b: bytes) -> str:
267
+ # helper function base64 encode for jose spec
268
+ return base64.urlsafe_b64encode(b).decode("utf8").replace("=", "")
269
+
270
+
271
+ def md5_text(text: Union[bytes, str]) -> str:
272
+ if isinstance(text, str):
273
+ text = text.encode()
274
+ return hashlib.md5(text).hexdigest()
275
+
276
+
277
+ def new_pem_tempfile(pem_data: str) -> tempfile._TemporaryFileWrapper:
278
+ """
279
+ this is just a convenience wrapper to create a tempfile and seek(0)
280
+
281
+ :param pem_data: PEM encoded string to seed the tempfile with
282
+ :type pem_data: str
283
+ :returns: a tempfile instance
284
+ :rtype: tempfile.NamedTemporaryFile
285
+ """
286
+ tmpfile_pem = tempfile.NamedTemporaryFile()
287
+ if isinstance(pem_data, str):
288
+ pem_bytes = pem_data.encode()
289
+ tmpfile_pem.write(pem_bytes)
290
+ tmpfile_pem.seek(0)
291
+ return tmpfile_pem
292
+
293
+
294
+ def new_der_tempfile(der_data: bytes) -> tempfile._TemporaryFileWrapper:
295
+ """
296
+ this is just a convenience wrapper to create a tempfile and seek(0)
297
+
298
+ :param der_data: DER encoded string to seed the tempfile with
299
+ :type der_data: str
300
+ :returns: a tempfile instance
301
+ :rtype: `tempfile.NamedTemporaryFile`
302
+ """
303
+ tmpfile_der = tempfile.NamedTemporaryFile()
304
+ tmpfile_der.write(der_data)
305
+ tmpfile_der.seek(0)
306
+ return tmpfile_der
307
+
308
+
309
+ def split_pem_chain(pem_text: str) -> List[str]:
310
+ """
311
+ splits a PEM encoded Certificate chain into multiple Certificates
312
+
313
+ :param pem_text: PEM formatted string containing one or more Certificates
314
+ :type pem_text: str
315
+ :returns: a list of PEM encoded Certificates
316
+ :rtype: list
317
+ """
318
+ _certs = CERT_PEM_REGEX.findall(pem_text)
319
+ certs = [cleanup_pem_text(i) for i in _certs]
320
+ return certs
321
+
322
+
323
+ def validate_domains(
324
+ domain_names: Iterable[str],
325
+ allow_hostname: bool = True,
326
+ allow_ipv4: bool = False,
327
+ allow_ipv6: bool = False,
328
+ ipv6_require_compressed: bool = False,
329
+ ) -> bool:
330
+ """
331
+ Ensures each items of the iterable `domain_names` is qualified for inclusion
332
+ in a x509 certificate.
333
+
334
+ This does a rudimentary check against hostnames (default) and optionally
335
+ ipv4 and ipv6.
336
+
337
+ see also: identify_san_type
338
+
339
+ :param domain_names: (required) An iterable list of strings
340
+ :param allow_hostname: bool, default True
341
+ added in 1.0.4
342
+ :param allow_ipv4: bool, default False
343
+ added in 1.0.4
344
+ :param allow_ipv6: bool, default False
345
+ added in 1.0.4
346
+ :param ipv6_require_compressed: bool, default False
347
+ added in 1.0.5
348
+ """
349
+ for d in domain_names:
350
+ if allow_hostname and RE_cert_domain.match(d):
351
+ if RE_ipv4.match(d):
352
+ if not allow_ipv4:
353
+ raise ValueError("invalid domain: `%s`" % d)
354
+ continue
355
+ if allow_ipv4 and RE_ipv4.match(d):
356
+ continue
357
+ if allow_ipv6 and RE_ipv6.match(d):
358
+ # require compressed
359
+ if ipv6_require_compressed:
360
+ validate_ipv6_address(d, ipv6_require_compressed=True)
361
+ # TODO: drop out special prefixes
362
+ # https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
363
+ continue
364
+ raise ValueError("invalid domain: `%s`" % d)
365
+ return True
366
+
367
+
368
+ def validate_ipv6_address(
369
+ domain_name: str,
370
+ ipv6_require_compressed: bool = False,
371
+ ) -> Literal[True]:
372
+ try:
373
+ # _obj = ipaddress.ip_address(domain_name)
374
+ _obj = ipaddress.IPv6Address(domain_name)
375
+ if ipv6_require_compressed:
376
+ if _obj.compressed != domain_name:
377
+ raise ValueError(
378
+ "invalid domain: `%s`; ipv6 must be compressed." % domain_name
379
+ )
380
+ return True
381
+ except ipaddress.AddressValueError as exc:
382
+ raise ValueError(
383
+ 'invalid domain: `%s`; ipaddress.AddressValueError("%s").'
384
+ % (domain_name, exc.args[0])
385
+ )
386
+
387
+
388
+ # ------------------------------------------------------------------------------
@@ -0,0 +1,259 @@
1
+ Metadata-Version: 2.4
2
+ Name: cert_utils
3
+ Version: 1.2.0
4
+ Summary: TLS/SSL Certificate Utilities
5
+ Home-page: https://github.com/aptise/cert_utils
6
+ Author: Jonathan Vanasco
7
+ Author-email: jonathan@findmeon.com
8
+ License: MIT
9
+ Keywords: web
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Python: >=3.7
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE.txt
24
+ Requires-Dist: python-dateutil
25
+ Requires-Dist: psutil>=4.4.0
26
+ Requires-Dist: requests
27
+ Requires-Dist: typing_extensions
28
+ Requires-Dist: importlib_metadata; python_version < "3.8"
29
+ Provides-Extra: testing
30
+ Requires-Dist: certbot; extra == "testing"
31
+ Requires-Dist: cryptography>=42.0.0; extra == "testing"
32
+ Requires-Dist: josepy<3; extra == "testing"
33
+ Requires-Dist: mypy; extra == "testing"
34
+ Requires-Dist: pytest; extra == "testing"
35
+ Requires-Dist: pytest-cov; extra == "testing"
36
+ Requires-Dist: pyOpenSSL; extra == "testing"
37
+ Requires-Dist: coverage[toml]; extra == "testing"
38
+ Requires-Dist: types-psutil; extra == "testing"
39
+ Requires-Dist: types-python-dateutil; extra == "testing"
40
+ Requires-Dist: types-requests; extra == "testing"
41
+ Provides-Extra: josepy
42
+ Requires-Dist: josepy<3; extra == "josepy"
43
+ Dynamic: license-file
44
+
45
+ ![Python package](https://github.com/jvanasco/cert_utils/workflows/Python%20package/badge.svg)
46
+
47
+ cert_utils
48
+ ==========
49
+
50
+ **cert_utils** offers support for common operations when dealing with SSL
51
+ Certificates, specifically within the LetsEncrypt ecosystem.
52
+
53
+ This library was originally developed as a toolkit for bugfixing and
54
+ troubleshooting large ACME installations.
55
+
56
+ **cert_utils** will attempt to process operations with Python via the modern
57
+ [Cryptography](https://cryptography.io/en/latest/) package when possible.
58
+ If the required Python libraries are not installed, it will fallback to using
59
+ OpenSSL commandline via subprocesses. **cert_utils** does a bit of work to
60
+ standardize certificate operations across versions of Python and OpenSSL that
61
+ do not share the same inputs, outputs or invocations.
62
+
63
+ **cert_utils** was formerly part of the
64
+ **[peter_sslers](https://github.com/aptise/peter_sslers)** ACME Client and
65
+ Certificate Management System, and has been descoped into it's own library.
66
+
67
+ This library *does not* process Certificates and Certificate Data itself.
68
+ Instead, it offers a simplified API to invoke other libraries and extract data
69
+ from Certificates. It was designed for developers and system administrators to
70
+ more easily use the various libraries to accomplish specific tasks on the
71
+ commandline or as part of other projects.
72
+
73
+ This library now includes a utility, `cert_info`:
74
+
75
+ Examples CLI:
76
+ -------------
77
+
78
+ The CLI tool `cert_info` can parse a cert and pull the active ARI info.
79
+
80
+ The tool accepts a filepath to a local file OR a https URL to be inspected live:
81
+
82
+ > cert_info https://letsencrypt.org
83
+
84
+ This will gnerate the output:
85
+
86
+ ********************************************************************************
87
+ ********************************************************************************
88
+ Process URL:
89
+ url: https://letsencrypt.org
90
+ =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
91
+ Connection Data:
92
+ {'cert_dict': {'OCSP': ('http://e6.o.lencr.org',),
93
+ 'caIssuers': ('http://e6.i.lencr.org/',),
94
+ 'issuer': ((('countryName', 'US'),),
95
+ (('organizationName', "Let's Encrypt"),),
96
+ (('commonName', 'E6'),)),
97
+ 'notAfter': 'Mar 7 14:44:04 2025 GMT',
98
+ 'notBefore': 'Dec 7 14:44:05 2024 GMT',
99
+ 'serialNumber': '04C7A73A361AC02B67BDBD6AF210285B1CC7',
100
+ 'subject': ((('commonName', 'letsencrypt.org'),),),
101
+ 'subjectAltName': (('DNS', 'lencr.org'),
102
+ ('DNS', 'letsencrypt.com'),
103
+ ('DNS', 'letsencrypt.org'),
104
+ ('DNS', 'www.lencr.org'),
105
+ ('DNS', 'www.letsencrypt.com'),
106
+ ('DNS', 'www.letsencrypt.org')),
107
+ 'version': 3},
108
+ 'cert_pem': '-----BEGIN CERTIFICATE-----\n'
109
+ 'MIID1DCCA1qgAwIBAgISBMenOjYawCtnvb1q8hAoWxzHMAoGCCqGSM49BAMDMDIx\n'
110
+ 'CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF\n'
111
+ 'NjAeFw0yNDEyMDcxNDQ0MDVaFw0yNTAzMDcxNDQ0MDRaMBoxGDAWBgNVBAMTD2xl\n'
112
+ 'dHNlbmNyeXB0Lm9yZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDgHHaIeMwYz\n'
113
+ 'APO9ZgfeGansUeFRN0sW4K23nrVppRXfjHvN+83vtH170hshCrIAZRJCL+CPcA2N\n'
114
+ 'UqmLiSdM/M+jggJmMIICYjAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYB\n'
115
+ 'BQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFD2MLtcnwBWM\n'
116
+ 'FukcX4wMfYCQUCMAMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMFUG\n'
117
+ 'CCsGAQUFBwEBBEkwRzAhBggrBgEFBQcwAYYVaHR0cDovL2U2Lm8ubGVuY3Iub3Jn\n'
118
+ 'MCIGCCsGAQUFBzAChhZodHRwOi8vZTYuaS5sZW5jci5vcmcvMG8GA1UdEQRoMGaC\n'
119
+ 'CWxlbmNyLm9yZ4IPbGV0c2VuY3J5cHQuY29tgg9sZXRzZW5jcnlwdC5vcmeCDXd3\n'
120
+ 'dy5sZW5jci5vcmeCE3d3dy5sZXRzZW5jcnlwdC5jb22CE3d3dy5sZXRzZW5jcnlw\n'
121
+ 'dC5vcmcwEwYDVR0gBAwwCjAIBgZngQwBAgEwggEEBgorBgEEAdZ5AgQCBIH1BIHy\n'
122
+ 'APAAdgB9WR4S4XgqexxhZ3xe/fjQh1wUoE6VnrkDL9kOjC55uAAAAZOhyXh9AAAE\n'
123
+ 'AwBHMEUCIAXoVc0BRK6ELL0Q1lHv+QbrobnBiPthcr2E7g0IulkHAiEAlVkiVkF9\n'
124
+ '7KeR/At3XdJNRYbJda0iKaKtqKy0KH7igZgAdgATSt8atZhCCXgMb+9MepGkFrcj\n'
125
+ 'Sc5YV2rfrtqnwqvgIgAAAZOhyXk0AAAEAwBHMEUCIQD+yhm/6lwMMWyxBaB15Mkh\n'
126
+ 'g3tsQCtr0DOD4uvAzc63FAIgHgjXduGCbB/3VTtRCGWfmkWSS9z+CjfO1O6hkzGW\n'
127
+ 'XBowCgYIKoZIzj0EAwMDaAAwZQIxAKVLzxW03Kk8qJMBtWQCw5dgSHNWOGG9Oq03\n'
128
+ 'XOGr8092sx9Ezz3XsXWeq8JvokFjhAIwCZOX/UjxdHfgV/O6gkiNARQ1FokDyfS0\n'
129
+ 'ClynPk9psMTPVl5QWgYJzjVrJagAjtJm\n'
130
+ '-----END CERTIFICATE-----\n',
131
+ 'peername': ('100.28.201.155', 443)}
132
+ =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
133
+ =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
134
+ Cert Data:
135
+ ARI ID: kydGmAOpUWiOmNbEQkjbI79YlNI.BMenOjYawCtnvb1q8hAoWxzH
136
+ =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
137
+ Requesting ARI
138
+ ari_url: https://acme-v02.api.letsencrypt.org/draft-ietf-acme-ari-03/renewalInfo/kydGmAOpUWiOmNbEQkjbI79YlNI.BMenOjYawCtnvb1q8hAoWxzH
139
+ {'Server': 'nginx', 'Date': 'Tue, 10 Dec 2024 18:29:04 GMT', 'Content-Type': 'application/json', 'Content-Length': '101', 'Connection': 'keep-alive', 'Cache-Control': 'public, max-age=0, no-cache', 'Link': '<https://acme-v02.api.letsencrypt.org/directory>;rel="index"', 'Retry-After': '21600', 'X-Frame-Options': 'DENY', 'Strict-Transport-Security': 'max-age=604800'}
140
+ - - - - - - - - - -
141
+ {'suggestedWindow': {'end': '2025-02-06T15:03:34Z',
142
+ 'start': '2025-02-04T15:03:34Z'}}
143
+
144
+
145
+ Examples API:
146
+ -------------
147
+
148
+ For example, `cert_utils.parse_cert` returns a Python dict of key fields in a
149
+ certificate. This can make writing a script to analyze large directories of
150
+ certificates fairly simple.
151
+
152
+
153
+ ### Parse a Leaf/End-Entity
154
+
155
+ Example Script:
156
+
157
+ ```!python
158
+ import cert_utils
159
+ import pprint
160
+
161
+ cert_path = "./tests/test_data/unit_tests/cert_001/cert.pem"
162
+ cert_pem = open(cert_path, 'r').read()
163
+ data = cert_utils.parse_cert(cert_pem)
164
+ pprint.pprint(data)
165
+ ```
166
+
167
+ Result:
168
+
169
+ {'SubjectAlternativeName': ['a.example.com',
170
+ 'b.example.com',
171
+ 'c.example.com',
172
+ 'd.example.com'],
173
+ 'authority_key_identifier': 'D159010094B0A62ADBABE54B2321CA1B6EBA93E7',
174
+ 'enddate': datetime.datetime(2025, 6, 16, 20, 19, 30),
175
+ 'fingerprint_sha1': 'F63C5C66B52551EEDADF7CE44301D646680B8F5D',
176
+ 'issuer': 'CN=Pebble Intermediate CA 601ea1',
177
+ 'issuer_uri': None,
178
+ 'key_technology': 'RSA',
179
+ 'spki_sha256': '34E67CC615761CBADAF430B2E02E0EC39C99EEFC73CCE469B18AE54A37EF6942',
180
+ 'startdate': datetime.datetime(2020, 6, 16, 20, 19, 30),
181
+ 'subject': 'CN=a.example.com'}
182
+
183
+ The payload contains `SubjectAlternativeName` listing all the domains, along
184
+ with `enddate` and `startdate` in Python datetime objects for easy comparison.
185
+
186
+ ### Parse a Trusted Root
187
+
188
+ Example Script:
189
+
190
+ ```!python
191
+ import cert_utils
192
+ import pprint
193
+
194
+ cert_path = "./src/cert_utils/letsencrypt-certs/isrgrootx1.pem"
195
+ cert_pem = open(cert_path, 'r').read()
196
+ data = cert_utils.parse_cert(cert_pem)
197
+ pprint.pprint(data)
198
+ ```
199
+
200
+ Result:
201
+
202
+ {'SubjectAlternativeName': None,
203
+ 'authority_key_identifier': None,
204
+ 'enddate': datetime.datetime(2035, 6, 4, 11, 4, 38),
205
+ 'fingerprint_sha1': 'CABD2A79A1076A31F21D253635CB039D4329A5E8',
206
+ 'issuer': 'C=US\nO=Internet Security Research Group\nCN=ISRG Root X1',
207
+ 'issuer_uri': None,
208
+ 'key_technology': 'RSA',
209
+ 'spki_sha256': '0B9FA5A59EED715C26C1020C711B4F6EC42D58B0015E14337A39DAD301C5AFC3',
210
+ 'startdate': datetime.datetime(2015, 6, 4, 11, 4, 38),
211
+ 'subject': 'C=US\nO=Internet Security Research Group\nCN=ISRG Root X1'}
212
+
213
+ The payload on Trusted Roots is identical.
214
+
215
+
216
+ Why does this exist?
217
+ --------------------
218
+
219
+ The [peter_sslers](https://github.com/aptise/peter_sslers) project was designed
220
+ to deploy on a wide variety of production servers that did not share common
221
+ Python and OpenSSL installations. Earlier versions of the library
222
+ (within peter_sslers) supported both Python2.7 and Python3, as it was common to
223
+ encounter a machine that did not have Python3 installed. Although it is still
224
+ common to find these machines, Python2.7 was dropped to take advantage of
225
+ typing. Depending on the version of OpenSSL installed on a system,
226
+ **cert_utils** will invoke the binary or regex the output to bridge support
227
+ through a unified interface.
228
+
229
+ Historical implementation notes: [OpenSSL reference](docs/openssl-reference.md).
230
+
231
+ HTTP timeouts
232
+ -------------
233
+
234
+ `cert_info` uses a Requests `(connect, read)` timeout of `(5, 30)` seconds.
235
+ The read timeout limits inactivity between reads; it is not a total command
236
+ execution deadline. Override both values with positive, finite seconds:
237
+
238
+ ```sh
239
+ CERT_UTILS_TIMEOUT=3,20 cert_info https://example.com
240
+ cert_info --timeout 2,10 https://example.com
241
+ cert_info --timeout 2,10 /path/to/cert.pem
242
+ ```
243
+
244
+ Precedence is explicit CLI/Python argument, then `CERT_UTILS_TIMEOUT`, then the
245
+ default. The CLI override also applies to directory and subsequent ARI requests.
246
+ Python callers can pass `timeout=(connect, read)` to `new_session`, `ari_query`,
247
+ `process_url`, `process_filepath`, `process_ari_url`, and
248
+ `download_letsencrypt_certificates`. Sessions from `new_session` retain Requests'
249
+ per-request timeout override support. CLI-owned sessions are closed on success
250
+ and failure; callers retain ownership of sessions passed to `process_ari_url`.
251
+
252
+ Compatibility design notes: [certificate profiles and peer hooks](docs/compatibility.md).
253
+
254
+ Bundled CA snapshot and migration notes: [Let's Encrypt certificate archive](docs/letsencrypt-bundle.md).
255
+
256
+ Machine-readable production rate policy: [schema 2 and migration guide](docs/rate-limits.md).
257
+ Import `cert_utils.letsencrypt_info.rate_limits.RATE_LIMITS` without loading the CA archive.
258
+
259
+ Account-signature formats and backend behavior: [signing and verification](docs/account-signatures.md).