bear-tools 0.2.3__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.
Files changed (44) hide show
  1. {bear_tools-0.2.3 → bear_tools-0.2.4}/PKG-INFO +2 -1
  2. {bear_tools-0.2.3 → bear_tools-0.2.4}/pyproject.toml +2 -1
  3. bear_tools-0.2.4/src/bear_tools/__init__.py +19 -0
  4. bear_tools-0.2.4/src/bear_tools/security_utils.py +555 -0
  5. bear_tools-0.2.3/src/bear_tools/__init__.py +0 -10
  6. {bear_tools-0.2.3 → bear_tools-0.2.4}/LICENSE +0 -0
  7. {bear_tools-0.2.3 → bear_tools-0.2.4}/README.md +0 -0
  8. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/__init__.py +0 -0
  9. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/client.py +0 -0
  10. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/mods/__init__.py +0 -0
  11. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/mods/base.py +0 -0
  12. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/serial_manager.py +0 -0
  13. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/server.py +0 -0
  14. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/cereal/tokenizer.py +0 -0
  15. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/dict_utils.py +0 -0
  16. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/enhanced_enum.py +0 -0
  17. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/enhanced_int_enum.py +0 -0
  18. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/example_protocol.yaml +0 -0
  19. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/fsm/README.md +0 -0
  20. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/fsm/__init__.py +0 -0
  21. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/fsm/fsm.py +0 -0
  22. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/linting_utils.py +0 -0
  23. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/README.md +0 -0
  24. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/__init__.py +0 -0
  25. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/callback_config.py +0 -0
  26. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/color.py +0 -0
  27. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/log_level.py +0 -0
  28. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/lumberjack/logger.py +0 -0
  29. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/macos_utils.py +0 -0
  30. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/markdown_utils.py +0 -0
  31. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/misc_utils.py +0 -0
  32. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/network_utils.py +0 -0
  33. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/os_utils.py +0 -0
  34. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/publisher/__init__.py +0 -0
  35. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/publisher/listener.py +0 -0
  36. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/publisher/publisher.py +0 -0
  37. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/py.typed +0 -0
  38. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/spreadsheet_utils.py +0 -0
  39. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/string_utils.py +0 -0
  40. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/thread_utils.py +0 -0
  41. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/time_utils.py +0 -0
  42. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/transport_protocol.py +0 -0
  43. {bear_tools-0.2.3 → bear_tools-0.2.4}/src/bear_tools/type_defs.py +0 -0
  44. {bear_tools-0.2.3 → 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
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"
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
+ ]
@@ -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)
@@ -1,10 +0,0 @@
1
- from bear_tools import enhanced_enum, enhanced_int_enum, misc_utils, string_utils, transport_protocol, yaml_utils
2
-
3
- __all__ = [
4
- 'enhanced_enum',
5
- 'enhanced_int_enum',
6
- 'misc_utils',
7
- 'string_utils',
8
- 'transport_protocol',
9
- 'yaml_utils',
10
- ]
File without changes
File without changes