bear-tools 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bear_tools-0.2.2 → bear_tools-0.2.4}/PKG-INFO +2 -1
- {bear_tools-0.2.2 → bear_tools-0.2.4}/pyproject.toml +2 -1
- bear_tools-0.2.4/src/bear_tools/__init__.py +19 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/macos_utils.py +4 -1
- bear_tools-0.2.4/src/bear_tools/security_utils.py +555 -0
- bear_tools-0.2.2/src/bear_tools/__init__.py +0 -10
- {bear_tools-0.2.2 → bear_tools-0.2.4}/LICENSE +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/README.md +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/__init__.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/client.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/mods/__init__.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/mods/base.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/serial_manager.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/server.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/cereal/tokenizer.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/dict_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/enhanced_enum.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/enhanced_int_enum.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/example_protocol.yaml +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/fsm/README.md +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/fsm/__init__.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/fsm/fsm.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/linting_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/README.md +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/__init__.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/callback_config.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/color.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/log_level.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/lumberjack/logger.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/markdown_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/misc_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/network_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/os_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/publisher/__init__.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/publisher/listener.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/publisher/publisher.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/py.typed +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/spreadsheet_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/string_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/thread_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/time_utils.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/transport_protocol.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/type_defs.py +0 -0
- {bear_tools-0.2.2 → bear_tools-0.2.4}/src/bear_tools/yaml_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: bear-tools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: An assortment of QA/Automation related tools
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Sean Foley
|
|
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: cryptography (>=48.0.0,<49.0.0)
|
|
15
16
|
Requires-Dist: jsonalias (>=0.1.2,<0.2.0)
|
|
16
17
|
Requires-Dist: netifaces (>=0.11.0,<0.12.0)
|
|
17
18
|
Requires-Dist: openpyxl (>=3.1.5,<4.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "bear-tools"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "An assortment of QA/Automation related tools"
|
|
5
5
|
authors = ["Sean Foley <sean.foley.engr@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -21,6 +21,7 @@ wcwidth = "^0.2.14"
|
|
|
21
21
|
openpyxl = "^3.1.5"
|
|
22
22
|
pandas = "^2.3.3"
|
|
23
23
|
pyserial = "^3.5"
|
|
24
|
+
cryptography = "^48.0.0"
|
|
24
25
|
|
|
25
26
|
[tool.poetry.group.dev.dependencies]
|
|
26
27
|
flake8 = "^7.2.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from bear_tools import (
|
|
2
|
+
enhanced_enum,
|
|
3
|
+
enhanced_int_enum,
|
|
4
|
+
misc_utils,
|
|
5
|
+
security_utils,
|
|
6
|
+
string_utils,
|
|
7
|
+
transport_protocol,
|
|
8
|
+
yaml_utils,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'enhanced_enum',
|
|
13
|
+
'enhanced_int_enum',
|
|
14
|
+
'misc_utils',
|
|
15
|
+
'security_utils',
|
|
16
|
+
'string_utils',
|
|
17
|
+
'transport_protocol',
|
|
18
|
+
'yaml_utils',
|
|
19
|
+
]
|
|
@@ -46,7 +46,10 @@ def get_current_ssid_macos15(interface: str = 'en0') -> str | None:
|
|
|
46
46
|
"""
|
|
47
47
|
|
|
48
48
|
try:
|
|
49
|
-
output: str = subprocess.check_output(
|
|
49
|
+
output: str = subprocess.check_output(
|
|
50
|
+
['ipconfig', 'getsummary', f'{interface}'],
|
|
51
|
+
stderr=subprocess.DEVNULL
|
|
52
|
+
).decode()
|
|
50
53
|
# Require the captured ASCII token to run right up to the newline.
|
|
51
54
|
# This prevents partial matches like "Caf" from "CaféNet".
|
|
52
55
|
regex: str = r'\n\s+SSID : ([\x20-\x7E]{1,32})(?=\n)'
|
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security utilities for working with X.509/TLS certificates and the system trust store.
|
|
3
|
+
|
|
4
|
+
Provides helpers to generate self-signed certificates, inspect them (SANs, fingerprint, expiry),
|
|
5
|
+
match a certificate against a hostname/IP (RFC 9525), validate self-signed CA certificates, and
|
|
6
|
+
add/find/remove certificates in the OS trust store on macOS and Linux. Also includes a small
|
|
7
|
+
password-masking helper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cryptography import x509
|
|
19
|
+
from cryptography.exceptions import InvalidSignature
|
|
20
|
+
from cryptography.hazmat.backends import default_backend
|
|
21
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric import dsa, ec, padding, rsa
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes
|
|
24
|
+
from cryptography.x509 import BasicConstraints
|
|
25
|
+
from cryptography.x509.oid import ExtensionOID, NameOID
|
|
26
|
+
|
|
27
|
+
from bear_tools import lumberjack
|
|
28
|
+
|
|
29
|
+
logger = lumberjack.Logger()
|
|
30
|
+
|
|
31
|
+
# macOS keeps its truststore in the "Keychain Access" app and provides the `security` CLI to manage it.
|
|
32
|
+
_MACOS_TRUSTSTORE = '/Users/$USER/Library/Keychains/login.keychain'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_linux() -> bool:
|
|
36
|
+
"""Return True if running on Linux."""
|
|
37
|
+
return sys.platform.startswith('linux')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _linux_ca_anchor() -> tuple[Path, str, str] | None:
|
|
41
|
+
"""
|
|
42
|
+
Resolve this Linux distro's system CA anchor directory and refresh commands.
|
|
43
|
+
|
|
44
|
+
Supports the two dominant conventions:
|
|
45
|
+
- Debian/Ubuntu: ``/usr/local/share/ca-certificates`` + ``update-ca-certificates``
|
|
46
|
+
- RHEL/Fedora: ``/etc/pki/ca-trust/source/anchors`` + ``update-ca-trust extract``
|
|
47
|
+
|
|
48
|
+
:return: Tuple of (anchor_dir, add_refresh_cmd, delete_refresh_cmd), or None if no supported
|
|
49
|
+
trust store is detected. Refresh commands are prefixed with ``sudo`` (system store needs root).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
debian_dir = Path('/usr/local/share/ca-certificates')
|
|
53
|
+
if debian_dir.is_dir() and shutil.which('update-ca-certificates'):
|
|
54
|
+
return debian_dir, 'sudo update-ca-certificates', 'sudo update-ca-certificates --fresh'
|
|
55
|
+
|
|
56
|
+
rhel_dir = Path('/etc/pki/ca-trust/source/anchors')
|
|
57
|
+
if rhel_dir.is_dir() and shutil.which('update-ca-trust'):
|
|
58
|
+
return rhel_dir, 'sudo update-ca-trust extract', 'sudo update-ca-trust extract'
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cert_common_name(cert: x509.Certificate) -> str | None:
|
|
64
|
+
"""Return a certificate's Subject Common Name, or None if it has none."""
|
|
65
|
+
attributes = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
|
66
|
+
return str(attributes[0].value) if attributes else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _safe_load_common_name(path: Path) -> str | None:
|
|
70
|
+
"""Load a cert file and return its Common Name, swallowing read errors (returns None)."""
|
|
71
|
+
try:
|
|
72
|
+
return _cert_common_name(load_cert(path))
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _iter_anchor_certs(anchor_dir: Path) -> list[Path]:
|
|
78
|
+
"""List candidate certificate files (``*.crt`` and ``*.pem``) in a Linux anchor directory."""
|
|
79
|
+
return sorted(anchor_dir.glob('*.crt')) + sorted(anchor_dir.glob('*.pem'))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_cert(cert_name: str) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Search for a certificate in the truststore
|
|
85
|
+
|
|
86
|
+
:param cert_name: Common Name of the certificate
|
|
87
|
+
:return: True if the certificate was found in the truststore; False otherwise
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# macOS
|
|
91
|
+
if sys.platform == 'darwin':
|
|
92
|
+
bash_command: str = f'security find-certificate -c "{cert_name}" "{_MACOS_TRUSTSTORE}"'
|
|
93
|
+
try:
|
|
94
|
+
subprocess.check_call(bash_command, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.DEVNULL)
|
|
95
|
+
except subprocess.CalledProcessError:
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
# Linux: scan the distro's CA anchor directory for a cert whose Common Name matches
|
|
100
|
+
if _is_linux():
|
|
101
|
+
anchor = _linux_ca_anchor()
|
|
102
|
+
if anchor is None:
|
|
103
|
+
logger.error('No supported Linux trust store found (need update-ca-certificates or update-ca-trust)')
|
|
104
|
+
return False
|
|
105
|
+
anchor_dir, _, _ = anchor
|
|
106
|
+
return any(_safe_load_common_name(crt) == cert_name for crt in _iter_anchor_certs(anchor_dir))
|
|
107
|
+
|
|
108
|
+
logger.error(f'Platform not supported: "{sys.platform}"')
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def delete_cert(cert_name: str, ignore_errors: bool = False) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Delete and un-trust a cert from the truststore
|
|
115
|
+
|
|
116
|
+
:param cert_name: Name of cert to un-trust and delete from truststore
|
|
117
|
+
:param ignore_errors: If True, do not log or otherwise indicate errors that occur when trying to delete cert
|
|
118
|
+
:return: True if the operation was successful; False otherwise
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# macOS
|
|
122
|
+
if sys.platform == 'darwin':
|
|
123
|
+
bash_command: str = f'security delete-certificate -t -c "{cert_name}" "{_MACOS_TRUSTSTORE}"'
|
|
124
|
+
try:
|
|
125
|
+
subprocess.check_call(bash_command, shell=True)
|
|
126
|
+
except subprocess.CalledProcessError as error:
|
|
127
|
+
if not ignore_errors:
|
|
128
|
+
logger.error(f'Failed to send command to OS: "{bash_command}". Error: "{error}"')
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Linux: remove any anchor cert(s) whose Common Name matches, then refresh the system store
|
|
132
|
+
elif _is_linux():
|
|
133
|
+
anchor = _linux_ca_anchor()
|
|
134
|
+
if anchor is None:
|
|
135
|
+
if not ignore_errors:
|
|
136
|
+
logger.error('No supported Linux trust store found (need update-ca-certificates or update-ca-trust)')
|
|
137
|
+
return False
|
|
138
|
+
anchor_dir, _, refresh_delete = anchor
|
|
139
|
+
matches = [crt for crt in _iter_anchor_certs(anchor_dir) if _safe_load_common_name(crt) == cert_name]
|
|
140
|
+
if not matches:
|
|
141
|
+
if not ignore_errors:
|
|
142
|
+
logger.error(f'Certificate not found in trust store: "{cert_name}"')
|
|
143
|
+
return False
|
|
144
|
+
for crt in matches:
|
|
145
|
+
try:
|
|
146
|
+
subprocess.check_call(f"sudo rm -f '{crt}'", shell=True)
|
|
147
|
+
except subprocess.CalledProcessError as error:
|
|
148
|
+
if not ignore_errors:
|
|
149
|
+
logger.error(f'Failed to remove cert "{crt}". Error: "{error}"')
|
|
150
|
+
return False
|
|
151
|
+
try:
|
|
152
|
+
subprocess.check_call(refresh_delete, shell=True)
|
|
153
|
+
except subprocess.CalledProcessError as error:
|
|
154
|
+
if not ignore_errors:
|
|
155
|
+
logger.error(f'Failed to refresh trust store via "{refresh_delete}". Error: "{error}"')
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
else:
|
|
159
|
+
logger.error(f'Platform not supported: "{sys.platform}"')
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
logger.info(f'Certificate untrusted and removed from truststore: "{cert_name}"')
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_cert(source: Path | str) -> x509.Certificate:
|
|
167
|
+
"""
|
|
168
|
+
Load an SSL/TLS certificate from a file or raw PEM data.
|
|
169
|
+
|
|
170
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
171
|
+
:return: The parsed certificate
|
|
172
|
+
:raises RuntimeError: If the certificate cannot be read or parsed
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
cert_data: bytes
|
|
177
|
+
if isinstance(source, Path):
|
|
178
|
+
with open(source, "rb") as f:
|
|
179
|
+
cert_data = f.read()
|
|
180
|
+
elif isinstance(source, str):
|
|
181
|
+
cert_data = source.encode()
|
|
182
|
+
cert: x509.Certificate = x509.load_pem_x509_certificate(cert_data, backend=default_backend())
|
|
183
|
+
return cert
|
|
184
|
+
except (InvalidSignature, ValueError, FileNotFoundError, x509.ExtensionNotFound) as error:
|
|
185
|
+
raise RuntimeError('There was a problem with reading the cert. Error: "{error}"') from error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_openssl_installed() -> bool:
|
|
189
|
+
"""
|
|
190
|
+
Determine if the system has openssl installed
|
|
191
|
+
|
|
192
|
+
:return: True if the ``openssl`` executable is available on PATH; False otherwise
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
subprocess.check_call('hash openssl', shell=True)
|
|
197
|
+
return True
|
|
198
|
+
except subprocess.CalledProcessError:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def mask_password(password: str, unmasked_length: int = 0, symbol: str = '*') -> str:
|
|
203
|
+
"""
|
|
204
|
+
Mask a password by substituting all but the last unmasked_length characters with symbol
|
|
205
|
+
|
|
206
|
+
:param password: A password in plain text to mask
|
|
207
|
+
:param unmasked_length: How many characters at the end of the password to not mask
|
|
208
|
+
:param symbol: The symbol to use for substitution
|
|
209
|
+
:return: The masked password
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
if len(password) < unmasked_length:
|
|
213
|
+
unmasked_length = len(password)
|
|
214
|
+
|
|
215
|
+
masked_length = len(password) - unmasked_length
|
|
216
|
+
return symbol * masked_length + password[masked_length:]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def trust_cert(cert_path: str) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Add a certificate to the user's truststore
|
|
222
|
+
|
|
223
|
+
:param cert_path: Path to a certificate that user wants to trust
|
|
224
|
+
:return: True if operation was successful; False otherwise
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
if not os.path.exists(cert_path):
|
|
228
|
+
logger.error(f'File not found: {cert_path}')
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# macOS
|
|
232
|
+
if sys.platform == 'darwin':
|
|
233
|
+
bash_command: str = f'security import {cert_path} -k {_MACOS_TRUSTSTORE}'
|
|
234
|
+
try:
|
|
235
|
+
subprocess.check_call(bash_command, shell=True)
|
|
236
|
+
except subprocess.CalledProcessError as error:
|
|
237
|
+
logger.error(f'Failed to send command to OS: "{bash_command}". Error: "{error}"')
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
# Linux: copy the cert into the distro's anchor directory and rebuild the system bundle
|
|
241
|
+
elif _is_linux():
|
|
242
|
+
anchor = _linux_ca_anchor()
|
|
243
|
+
if anchor is None:
|
|
244
|
+
logger.error('No supported Linux trust store found (need update-ca-certificates or update-ca-trust)')
|
|
245
|
+
return False
|
|
246
|
+
anchor_dir, refresh_add, _ = anchor
|
|
247
|
+
# update-ca-certificates only ingests files with a .crt extension
|
|
248
|
+
destination: Path = anchor_dir / f'{Path(cert_path).stem}.crt'
|
|
249
|
+
for command in (f"sudo cp '{cert_path}' '{destination}'", refresh_add):
|
|
250
|
+
try:
|
|
251
|
+
subprocess.check_call(command, shell=True)
|
|
252
|
+
except subprocess.CalledProcessError as error:
|
|
253
|
+
logger.error(f'Failed to send command to OS: "{command}". Error: "{error}"')
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
else:
|
|
257
|
+
logger.error(f'Platform not supported: "{sys.platform}"')
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
logger.info(f'Certificate added to truststore: {cert_path}')
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_self_signed_certificate_valid(cert_src: Path | str, current_time: datetime | None = None) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Verifies that a certificate is a valid, self-signed CA certificate.
|
|
267
|
+
|
|
268
|
+
Equivalent to: openssl verify -CAfile cert_path cert_path
|
|
269
|
+
|
|
270
|
+
:param cert_src: The source of a self-signed certificate. Can be a Path or raw data
|
|
271
|
+
:param current_time: If set, use this as the current time rather than the system's actual current time
|
|
272
|
+
:return: True if the certificate is valid; False otherwise
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
cert_data: bytes
|
|
277
|
+
if isinstance(cert_src, Path):
|
|
278
|
+
with open(cert_src, "rb") as f:
|
|
279
|
+
cert_data = f.read()
|
|
280
|
+
elif isinstance(cert_src, str):
|
|
281
|
+
cert_data = cert_src.encode()
|
|
282
|
+
|
|
283
|
+
cert: x509.Certificate = x509.load_pem_x509_certificate(cert_data, backend=default_backend())
|
|
284
|
+
public_key: PublicKeyTypes = cert.public_key()
|
|
285
|
+
|
|
286
|
+
hash_algorithm = cert.signature_hash_algorithm
|
|
287
|
+
if hash_algorithm is None:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
# 1. Verify signature
|
|
291
|
+
if isinstance(public_key, rsa.RSAPublicKey):
|
|
292
|
+
public_key.verify(
|
|
293
|
+
signature=cert.signature,
|
|
294
|
+
data=cert.tbs_certificate_bytes,
|
|
295
|
+
padding=padding.PKCS1v15(),
|
|
296
|
+
algorithm=hash_algorithm
|
|
297
|
+
)
|
|
298
|
+
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
|
299
|
+
public_key.verify(
|
|
300
|
+
signature=cert.signature,
|
|
301
|
+
data=cert.tbs_certificate_bytes,
|
|
302
|
+
signature_algorithm=ec.ECDSA(hash_algorithm)
|
|
303
|
+
)
|
|
304
|
+
elif isinstance(public_key, dsa.DSAPublicKey):
|
|
305
|
+
public_key.verify(
|
|
306
|
+
signature=cert.signature,
|
|
307
|
+
data=cert.tbs_certificate_bytes,
|
|
308
|
+
algorithm=hash_algorithm
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
return False # Unsupported key type
|
|
312
|
+
|
|
313
|
+
# 2. Validity window (UTC-aware)
|
|
314
|
+
before: datetime = cert.not_valid_before_utc
|
|
315
|
+
now: datetime = current_time or datetime.now(tz=timezone.utc)
|
|
316
|
+
after: datetime = cert.not_valid_after_utc
|
|
317
|
+
|
|
318
|
+
logger.debug(f'before: {before} (type: {type(before)})')
|
|
319
|
+
logger.debug(f'now: {now} ({type(now)})')
|
|
320
|
+
logger.debug(f'after: {after} ({type(after)})')
|
|
321
|
+
logger.debug(f'before <= now: {before <= now}')
|
|
322
|
+
logger.debug(f'now <= after: {now <= after}')
|
|
323
|
+
|
|
324
|
+
if not cert.not_valid_before_utc <= now <= cert.not_valid_after_utc:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
# 3. Basic Constraints: must be a CA
|
|
328
|
+
try:
|
|
329
|
+
bc = cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value
|
|
330
|
+
assert isinstance(bc, BasicConstraints), 'bc is not a BasicConstraints'
|
|
331
|
+
if not bc.ca:
|
|
332
|
+
return False
|
|
333
|
+
except x509.ExtensionNotFound:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
except (InvalidSignature, ValueError, FileNotFoundError, x509.ExtensionNotFound):
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _build_subject_alt_names(sans: list[str]) -> x509.SubjectAlternativeName:
|
|
343
|
+
"""
|
|
344
|
+
Convert a list of host identifiers into a SubjectAlternativeName extension.
|
|
345
|
+
|
|
346
|
+
Each entry is added as an IP address if it parses as one, otherwise as a DNS name. Embedding the
|
|
347
|
+
IP/hostname a client will connect to is required because modern TLS clients match against the SAN
|
|
348
|
+
and ignore the legacy Common Name field.
|
|
349
|
+
|
|
350
|
+
:param sans: List of IP addresses and/or DNS names to embed in the certificate
|
|
351
|
+
:return: A SubjectAlternativeName extension value
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
entries: list[x509.GeneralName] = []
|
|
355
|
+
for san in sans:
|
|
356
|
+
try:
|
|
357
|
+
entries.append(x509.IPAddress(ipaddress.ip_address(san)))
|
|
358
|
+
except ValueError:
|
|
359
|
+
entries.append(x509.DNSName(san))
|
|
360
|
+
return x509.SubjectAlternativeName(entries)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def generate_self_signed_cert(
|
|
364
|
+
sans: list[str],
|
|
365
|
+
common_name: str = 'Self-Signed Certificate',
|
|
366
|
+
valid_days: int = 825,
|
|
367
|
+
key_size: int = 2048,
|
|
368
|
+
cert_path: Path | None = None,
|
|
369
|
+
key_path: Path | None = None,
|
|
370
|
+
) -> tuple[bytes, bytes]:
|
|
371
|
+
"""
|
|
372
|
+
Generate a self-signed certificate and matching private key.
|
|
373
|
+
|
|
374
|
+
The certificate is marked as a CA (BasicConstraints CA=True) so it can act as its own trust
|
|
375
|
+
anchor: it may be used directly as a server cert/key and the same PEM distributed as the CA to
|
|
376
|
+
trust. It also satisfies ``is_self_signed_certificate_valid``.
|
|
377
|
+
|
|
378
|
+
:param sans: Host identifiers (IP addresses and/or DNS names) to embed in the SubjectAlternativeName.
|
|
379
|
+
Must include the address/hostname the client will connect to.
|
|
380
|
+
:param common_name: Subject/Issuer Common Name for the certificate
|
|
381
|
+
:param valid_days: Number of days the certificate remains valid (default 825 = ~27 months, the
|
|
382
|
+
CA/Browser-Forum max for leaf certs; kept conservative for tooling compatibility)
|
|
383
|
+
:param key_size: RSA key size in bits
|
|
384
|
+
:param cert_path: If set, write the PEM-encoded certificate to this path
|
|
385
|
+
:param key_path: If set, write the PEM-encoded private key to this path (chmod 0600)
|
|
386
|
+
:return: Tuple of (certificate_pem_bytes, private_key_pem_bytes)
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
key: rsa.RSAPrivateKey = rsa.generate_private_key(
|
|
390
|
+
public_exponent=65537,
|
|
391
|
+
key_size=key_size,
|
|
392
|
+
backend=default_backend(),
|
|
393
|
+
)
|
|
394
|
+
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
|
|
395
|
+
now: datetime = datetime.now(tz=timezone.utc)
|
|
396
|
+
|
|
397
|
+
cert: x509.Certificate = (
|
|
398
|
+
x509.CertificateBuilder()
|
|
399
|
+
.subject_name(name)
|
|
400
|
+
.issuer_name(name)
|
|
401
|
+
.public_key(key.public_key())
|
|
402
|
+
.serial_number(x509.random_serial_number())
|
|
403
|
+
.not_valid_before(now - timedelta(minutes=1))
|
|
404
|
+
.not_valid_after(now + timedelta(days=valid_days))
|
|
405
|
+
.add_extension(_build_subject_alt_names(sans), critical=False)
|
|
406
|
+
.add_extension(BasicConstraints(ca=True, path_length=None), critical=True)
|
|
407
|
+
.sign(private_key=key, algorithm=hashes.SHA256(), backend=default_backend())
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
cert_pem: bytes = cert.public_bytes(serialization.Encoding.PEM)
|
|
411
|
+
key_pem: bytes = key.private_bytes(
|
|
412
|
+
encoding=serialization.Encoding.PEM,
|
|
413
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
414
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if cert_path is not None:
|
|
418
|
+
cert_path.write_bytes(cert_pem)
|
|
419
|
+
logger.info(f'Wrote certificate to: {cert_path}')
|
|
420
|
+
if key_path is not None:
|
|
421
|
+
key_path.write_bytes(key_pem)
|
|
422
|
+
os.chmod(key_path, 0o600)
|
|
423
|
+
logger.info(f'Wrote private key to: {key_path}')
|
|
424
|
+
|
|
425
|
+
return cert_pem, key_pem
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_certificate_sans(source: Path | str) -> list[str]:
|
|
429
|
+
"""
|
|
430
|
+
Extract the SubjectAlternativeName entries (IP addresses and DNS names) from a certificate.
|
|
431
|
+
|
|
432
|
+
Useful to assert that a certificate actually covers the address/hostname a client will connect to.
|
|
433
|
+
|
|
434
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
435
|
+
:return: List of SAN values as strings (IP addresses and DNS names); empty if the SAN is absent
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
cert: x509.Certificate = load_cert(source)
|
|
439
|
+
try:
|
|
440
|
+
san = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
|
|
441
|
+
assert isinstance(san, x509.SubjectAlternativeName), 'extension value is not a SubjectAlternativeName'
|
|
442
|
+
except x509.ExtensionNotFound:
|
|
443
|
+
return []
|
|
444
|
+
|
|
445
|
+
names: list[str] = [str(ip) for ip in san.get_values_for_type(x509.IPAddress)]
|
|
446
|
+
names.extend(san.get_values_for_type(x509.DNSName))
|
|
447
|
+
return names
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def cert_matches_host(source: Path | str, host: str) -> bool:
|
|
451
|
+
"""
|
|
452
|
+
Check whether a certificate is valid for the given hostname or IP address.
|
|
453
|
+
|
|
454
|
+
Implements the SubjectAlternativeName matching that modern TLS clients perform (RFC 9525): IP
|
|
455
|
+
targets are matched against ``iPAddress`` SANs, and hostnames against ``dNSName`` SANs
|
|
456
|
+
(case-insensitive, with single-label ``*.`` wildcard support). The legacy Common Name field is
|
|
457
|
+
intentionally ignored, mirroring real client behavior.
|
|
458
|
+
|
|
459
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
460
|
+
:param host: The hostname or IP address the client would connect to
|
|
461
|
+
:return: True if the certificate covers ``host``; False otherwise
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
sans: list[str] = get_certificate_sans(source)
|
|
465
|
+
|
|
466
|
+
# IP target: compare normalized IP objects against IP-type SANs only
|
|
467
|
+
try:
|
|
468
|
+
target_ip = ipaddress.ip_address(host)
|
|
469
|
+
except ValueError:
|
|
470
|
+
target_ip = None
|
|
471
|
+
|
|
472
|
+
if target_ip is not None:
|
|
473
|
+
for san in sans:
|
|
474
|
+
try:
|
|
475
|
+
if ipaddress.ip_address(san) == target_ip:
|
|
476
|
+
return True
|
|
477
|
+
except ValueError:
|
|
478
|
+
continue
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
# Hostname target: case-insensitive exact or single-label wildcard match
|
|
482
|
+
host_normalized: str = host.lower().rstrip('.')
|
|
483
|
+
for san in sans:
|
|
484
|
+
san_normalized: str = san.lower().rstrip('.')
|
|
485
|
+
if san_normalized == host_normalized:
|
|
486
|
+
return True
|
|
487
|
+
if san_normalized.startswith('*.'):
|
|
488
|
+
suffix: str = san_normalized[1:] # e.g. ".example.com"
|
|
489
|
+
if host_normalized.endswith(suffix):
|
|
490
|
+
left_label: str = host_normalized[: -len(suffix)]
|
|
491
|
+
# Wildcard matches exactly one left-most label (no embedded dot, non-empty)
|
|
492
|
+
if left_label and '.' not in left_label:
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def cert_fingerprint(source: Path | str, algorithm: str = 'sha256', separator: str = ':') -> str:
|
|
498
|
+
"""
|
|
499
|
+
Compute a certificate's fingerprint (a hash of its DER bytes), as used for cert pinning.
|
|
500
|
+
|
|
501
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
502
|
+
:param algorithm: Hash algorithm: 'sha256' (default), 'sha512', or 'sha1' (legacy compatibility only)
|
|
503
|
+
:param separator: String placed between hex byte pairs (default ':' -> e.g. 'AB:CD:...')
|
|
504
|
+
:return: The uppercase hex fingerprint string
|
|
505
|
+
:raises ValueError: If ``algorithm`` is not one of the supported choices
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
algorithms: dict[str, hashes.HashAlgorithm] = {
|
|
509
|
+
'sha256': hashes.SHA256(),
|
|
510
|
+
'sha512': hashes.SHA512(),
|
|
511
|
+
'sha1': hashes.SHA1(), # noqa: S303 # identity/pinning use only, not a security signature
|
|
512
|
+
}
|
|
513
|
+
chosen = algorithms.get(algorithm.lower())
|
|
514
|
+
if chosen is None:
|
|
515
|
+
raise ValueError(f'Unsupported fingerprint algorithm: "{algorithm}". Choose from {sorted(algorithms)}')
|
|
516
|
+
|
|
517
|
+
digest: bytes = load_cert(source).fingerprint(chosen)
|
|
518
|
+
return separator.join(f'{byte:02X}' for byte in digest)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def get_cert_expiry(source: Path | str) -> datetime:
|
|
522
|
+
"""
|
|
523
|
+
Get a certificate's expiry (``notAfter``) as a timezone-aware UTC datetime.
|
|
524
|
+
|
|
525
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
526
|
+
:return: The UTC datetime after which the certificate is no longer valid
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
return load_cert(source).not_valid_after_utc
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def days_until_expiry(source: Path | str, current_time: datetime | None = None) -> int:
|
|
533
|
+
"""
|
|
534
|
+
Get the number of whole days until a certificate expires (negative if already expired).
|
|
535
|
+
|
|
536
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
537
|
+
:param current_time: If set, measure from this time rather than the system's actual current time
|
|
538
|
+
:return: Whole days until expiry; negative if the certificate has already expired
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
now: datetime = current_time or datetime.now(tz=timezone.utc)
|
|
542
|
+
return (get_cert_expiry(source) - now).days
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def is_cert_expired(source: Path | str, current_time: datetime | None = None) -> bool:
|
|
546
|
+
"""
|
|
547
|
+
Determine whether a certificate is past its expiry (``notAfter``) date.
|
|
548
|
+
|
|
549
|
+
:param source: Either a Path pointing to the cert file or a str containing the raw certificate data
|
|
550
|
+
:param current_time: If set, compare against this time rather than the system's actual current time
|
|
551
|
+
:return: True if the certificate has expired; False otherwise
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
now: datetime = current_time or datetime.now(tz=timezone.utc)
|
|
555
|
+
return now > get_cert_expiry(source)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|