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.
- cert_utils/__init__.py +8 -0
- cert_utils/compat/__init__.py +0 -0
- cert_utils/compat/cryptography.py +172 -0
- cert_utils/compat/openssl.py +860 -0
- cert_utils/compat/pyopenssl.py +207 -0
- cert_utils/compat/signatures.py +114 -0
- cert_utils/conditionals.py +121 -0
- cert_utils/convert.py +307 -0
- cert_utils/core.py +3075 -0
- cert_utils/errors.py +54 -0
- cert_utils/letsencrypt-certs/deprecated/e5-cross.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/e5.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/e6-cross.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/e6.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/e7-cross.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/e7.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/e8-cross.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/e8.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/e9-cross.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/e9.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/fakeleintermediatex1.pem +28 -0
- cert_utils/letsencrypt-certs/deprecated/fakelerootx1.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/isrg-root-ocsp-x1.pem +28 -0
- cert_utils/letsencrypt-certs/deprecated/isrg-root-x1-cross-signed.pem +31 -0
- cert_utils/letsencrypt-certs/deprecated/isrg-root-x2-cross-signed.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-e1.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-e2.pem +17 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r3-cross-signed.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r3.pem +30 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r4-cross-signed.pem +26 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-r4.pem +30 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x1-cross-signed.pem +27 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x2-cross-signed.pem +27 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x3-cross-signed.pem +27 -0
- cert_utils/letsencrypt-certs/deprecated/lets-encrypt-x4-cross-signed.pem +27 -0
- cert_utils/letsencrypt-certs/deprecated/letsencrypt-stg-root-x2-signed-by-x1.pem +27 -0
- cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx1.pem +32 -0
- cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx2.pem +32 -0
- cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx3.pem +32 -0
- cert_utils/letsencrypt-certs/deprecated/letsencryptauthorityx4.pem +32 -0
- cert_utils/letsencrypt-certs/deprecated/r10.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/r11.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/r12.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/r13.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/r14.pem +29 -0
- cert_utils/letsencrypt-certs/deprecated/trustid-x3-root.p7c +0 -0
- cert_utils/letsencrypt-certs/deprecated/trustid-x3-root.pem +20 -0
- cert_utils/letsencrypt-certs/int-ye1.pem +16 -0
- cert_utils/letsencrypt-certs/int-ye2.pem +16 -0
- cert_utils/letsencrypt-certs/int-ye3.pem +16 -0
- cert_utils/letsencrypt-certs/int-yr1.pem +28 -0
- cert_utils/letsencrypt-certs/int-yr2.pem +28 -0
- cert_utils/letsencrypt-certs/int-yr3.pem +28 -0
- cert_utils/letsencrypt-certs/isrg-root-x2.pem +14 -0
- cert_utils/letsencrypt-certs/isrgrootx1.pem +31 -0
- cert_utils/letsencrypt-certs/isrgrootx1.pkix +0 -0
- cert_utils/letsencrypt-certs/letsencrypt-stg-root-x1.pem +32 -0
- cert_utils/letsencrypt-certs/letsencrypt-stg-root-x2.pem +15 -0
- cert_utils/letsencrypt-certs/root-x2-by-x1.pem +26 -0
- cert_utils/letsencrypt-certs/root-ye-by-x2.pem +17 -0
- cert_utils/letsencrypt-certs/root-ye.pem +12 -0
- cert_utils/letsencrypt-certs/root-yr-by-x1.pem +34 -0
- cert_utils/letsencrypt-certs/root-yr.pem +30 -0
- cert_utils/letsencrypt_info/__init__.py +6 -0
- cert_utils/letsencrypt_info/certificates.py +1074 -0
- cert_utils/letsencrypt_info/rate_limits.py +414 -0
- cert_utils/letsencrypt_info/types.py +120 -0
- cert_utils/model.py +181 -0
- cert_utils/network.py +28 -0
- cert_utils/py.typed +0 -0
- cert_utils/scripts/__init__.py +0 -0
- cert_utils/scripts/cert_info.py +332 -0
- cert_utils/utils.py +388 -0
- cert_utils-1.2.0.dist-info/METADATA +259 -0
- cert_utils-1.2.0.dist-info/RECORD +79 -0
- cert_utils-1.2.0.dist-info/WHEEL +5 -0
- cert_utils-1.2.0.dist-info/entry_points.txt +2 -0
- cert_utils-1.2.0.dist-info/licenses/LICENSE.txt +224 -0
- 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
|
+

|
|
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).
|