pyCertWiz 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. pycert/__init__.py +15 -0
  2. pycert/__main__.py +6 -0
  3. pycert/asn1.py +135 -0
  4. pycert/ber.py +145 -0
  5. pycert/bundled.py +67 -0
  6. pycert/bundles.py +723 -0
  7. pycert/ca.py +693 -0
  8. pycert/certs.py +674 -0
  9. pycert/chain.py +370 -0
  10. pycert/cli.py +2299 -0
  11. pycert/config.py +1623 -0
  12. pycert/csr.py +192 -0
  13. pycert/data/__init__.py +0 -0
  14. pycert/data/ca/__init__.py +0 -0
  15. pycert/data/ca/escience-2b.pem +23 -0
  16. pycert/data/ca/escience-3b.pem +16 -0
  17. pycert/data/ca/escience-root-2026.pem +15 -0
  18. pycert/data/ca/escience-root.pem +21 -0
  19. pycert/deploy.py +481 -0
  20. pycert/diagnostics.py +337 -0
  21. pycert/errors.py +59 -0
  22. pycert/exporter.py +372 -0
  23. pycert/gui/__init__.py +107 -0
  24. pycert/gui/app.py +1776 -0
  25. pycert/gui/bundle.py +363 -0
  26. pycert/gui/dialogs.py +1093 -0
  27. pycert/gui/icons.py +144 -0
  28. pycert/gui/models.py +357 -0
  29. pycert/gui/settings.py +475 -0
  30. pycert/gui/setup.py +136 -0
  31. pycert/gui/theme.py +281 -0
  32. pycert/gui/tray.py +164 -0
  33. pycert/gui/viewer.py +350 -0
  34. pycert/gui/widgets.py +727 -0
  35. pycert/gui/wizard.py +630 -0
  36. pycert/gui/workers.py +232 -0
  37. pycert/keystore.py +862 -0
  38. pycert/monitor.py +256 -0
  39. pycert/p12.py +275 -0
  40. pycert/pkcs12_writer.py +343 -0
  41. pycert/providers/__init__.py +98 -0
  42. pycert/providers/acme.py +1204 -0
  43. pycert/providers/base.py +270 -0
  44. pycert/providers/ukca.py +399 -0
  45. pycert/proxy.py +254 -0
  46. pycert/secrets_store.py +449 -0
  47. pycert/store.py +614 -0
  48. pycert/transport.py +843 -0
  49. pycert/util.py +235 -0
  50. pycert/workflow.py +1702 -0
  51. pycertwiz-2.0.1.dist-info/METADATA +924 -0
  52. pycertwiz-2.0.1.dist-info/RECORD +56 -0
  53. pycertwiz-2.0.1.dist-info/WHEEL +5 -0
  54. pycertwiz-2.0.1.dist-info/entry_points.txt +7 -0
  55. pycertwiz-2.0.1.dist-info/licenses/LICENSE +339 -0
  56. pycertwiz-2.0.1.dist-info/top_level.txt +1 -0
pycert/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """pyCertWiz - a certificate manager for the UK e-Science CA (and ACME CAs).
2
+
3
+ Replaces the Java CertWizard tool and extends it into a manager for many user
4
+ and host certificates: request, wait for RA approval and CA signing, download
5
+ the issued certificate with its full chain, export encrypted PKCS#12 bundles,
6
+ deploy PEM files to services, monitor expiry and renew.
7
+
8
+ The public entry points are the ``pycert`` command line tool and the Qt GUI
9
+ (``pycert gui``). The library modules are importable and documented; the
10
+ orchestration lives in :mod:`pycert.workflow` and CA protocols live behind the
11
+ provider interface in :mod:`pycert.providers`.
12
+ """
13
+
14
+ __version__ = "2.0.1"
15
+ __all__ = ["__version__"]
pycert/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m pycert``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
pycert/asn1.py ADDED
@@ -0,0 +1,135 @@
1
+ """Minimal DER encoder used by the PKCS#12 writer.
2
+
3
+ Only encoding is implemented: pyCertWiz reads certificates and PKCS#12 through
4
+ ``cryptography``/OpenSSL, but writes the PKCS#12 container itself so the same
5
+ modern algorithms are used on every Python version (older ``cryptography``
6
+ releases write legacy 3DES/SHA-1 PKCS#12). The encoder is deliberately small
7
+ and covered by known-answer tests.
8
+ """
9
+
10
+ __all__ = [
11
+ "tlv",
12
+ "seq",
13
+ "set_of",
14
+ "integer",
15
+ "octet_string",
16
+ "oid",
17
+ "null",
18
+ "bmp_string",
19
+ "explicit",
20
+ "implicit",
21
+ "encode_oid",
22
+ "encode_length",
23
+ ]
24
+
25
+ UNIVERSAL = 0
26
+ APPLICATION = 1
27
+ CONTEXT = 2
28
+ PRIVATE = 3
29
+
30
+ BOOLEAN = 1
31
+ INTEGER = 2
32
+ BIT_STRING = 3
33
+ OCTET_STRING = 4
34
+ NULL = 5
35
+ OID = 6
36
+ UTF8_STRING = 12
37
+ SEQUENCE = 16
38
+ SET = 17
39
+ PRINTABLE_STRING = 19
40
+ IA5_STRING = 22
41
+ BMP_STRING = 30
42
+
43
+ _EOF = "\x00"
44
+
45
+
46
+ def encode_length(length):
47
+ if length < 0:
48
+ raise ValueError("negative length")
49
+ if length < 0x80:
50
+ return bytes((length,))
51
+ raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
52
+ if len(raw) > 4:
53
+ raise ValueError("length too large")
54
+ return bytes((0x80 | len(raw),)) + raw
55
+
56
+
57
+ def tlv(tag_class, constructed, tag_number, content):
58
+ first = (tag_class << 6) | (0x20 if constructed else 0)
59
+ if tag_number >= 0x1F:
60
+ raise ValueError("high tag numbers are not used by pyCertWiz")
61
+ return bytes((first | tag_number,)) + encode_length(len(content)) + content
62
+
63
+
64
+ def seq(*items):
65
+ return tlv(UNIVERSAL, True, SEQUENCE, b"".join(items))
66
+
67
+
68
+ def set_of(*items):
69
+ # DER requires SET OF elements to be sorted by their encoding.
70
+ return tlv(UNIVERSAL, True, SET, b"".join(sorted(items)))
71
+
72
+
73
+ def _int_content(value):
74
+ if not isinstance(value, int):
75
+ raise TypeError("integer expected")
76
+ if value == 0:
77
+ return b"\x00"
78
+ if value > 0:
79
+ raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
80
+ if raw[0] & 0x80:
81
+ raw = b"\x00" + raw
82
+ return raw
83
+ length = ((value.bit_length() + 8) // 8) + 1
84
+ raw = value.to_bytes(length, "big", signed=True)
85
+ while len(raw) > 1 and raw[0] == 0xFF and raw[1] & 0x80:
86
+ raw = raw[1:]
87
+ return raw
88
+
89
+
90
+ def integer(value):
91
+ return tlv(UNIVERSAL, False, INTEGER, _int_content(value))
92
+
93
+
94
+ def null():
95
+ return tlv(UNIVERSAL, False, NULL, b"")
96
+
97
+
98
+ def octet_string(value):
99
+ return tlv(UNIVERSAL, False, OCTET_STRING, value)
100
+
101
+
102
+ def bmp_string(value):
103
+ return tlv(UNIVERSAL, False, BMP_STRING, value.encode("utf-16-be"))
104
+
105
+
106
+ def _base128(value):
107
+ groups = [value & 0x7F]
108
+ value >>= 7
109
+ while value:
110
+ groups.append(value & 0x7F)
111
+ value >>= 7
112
+ groups.reverse()
113
+ return bytes(g | (0x80 if index < len(groups) - 1 else 0) for index, g in enumerate(groups))
114
+
115
+
116
+ def encode_oid(dotted):
117
+ parts = [int(part) for part in dotted.split(".")]
118
+ if len(parts) < 2 or not 0 <= parts[0] <= 2 or (parts[0] < 2 and parts[1] > 39):
119
+ raise ValueError("invalid OID %r" % dotted)
120
+ body = bytearray(_base128(parts[0] * 40 + parts[1]))
121
+ for part in parts[2:]:
122
+ body += _base128(part)
123
+ return bytes(body)
124
+
125
+
126
+ def oid(dotted):
127
+ return tlv(UNIVERSAL, False, OID, encode_oid(dotted))
128
+
129
+
130
+ def explicit(number, content, tag_class=CONTEXT):
131
+ return tlv(tag_class, True, number, content)
132
+
133
+
134
+ def implicit(number, content, tag_class=CONTEXT, constructed=False):
135
+ return tlv(tag_class, constructed, number, content)
pycert/ber.py ADDED
@@ -0,0 +1,145 @@
1
+ """Minimal BER/DER reader used for legacy PKCS#12 keystores.
2
+
3
+ Java's PKCS#12 writer emits BER with indefinite-length constructed objects,
4
+ whereas pyCertWiz's own writer emits DER. This module only implements the small
5
+ subset needed to inspect SafeBags; it is deliberately strict about boundaries
6
+ and nesting depth.
7
+ """
8
+
9
+ MAX_DEPTH = 64
10
+ MAX_NODES = 100000
11
+
12
+
13
+ class BERError(ValueError):
14
+ pass
15
+
16
+
17
+ class TLV:
18
+ """One parsed BER element, retaining its original encoding."""
19
+
20
+ __slots__ = ("tag_class", "constructed", "tag", "content", "children", "encoded")
21
+
22
+ def __init__(self, tag_class, constructed, tag, content, children, encoded):
23
+ self.tag_class = tag_class
24
+ self.constructed = constructed
25
+ self.tag = tag
26
+ self.content = content
27
+ self.children = children
28
+ self.encoded = encoded
29
+
30
+ def __repr__(self):
31
+ return "TLV(tag=%d, constructed=%r, length=%d)" % (
32
+ self.tag,
33
+ self.constructed,
34
+ len(self.encoded),
35
+ )
36
+
37
+ def require_constructed(self):
38
+ if not self.constructed or not self.children:
39
+ raise BERError("expected a constructed BER element")
40
+
41
+
42
+ def parse(data: bytes) -> TLV:
43
+ if not isinstance(data, (bytes, bytearray)):
44
+ raise BERError("BER input must be bytes")
45
+ node, offset = _parse_one(bytes(data), 0, len(data), 0, [0])
46
+ if offset != len(data):
47
+ raise BERError("trailing bytes after BER element")
48
+ return node
49
+
50
+
51
+ def decode_oid(content: bytes) -> str:
52
+ if not content:
53
+ raise BERError("empty object identifier")
54
+ first = content[0]
55
+ values = [str(first // 40), str(first % 40)]
56
+ value = 0
57
+ for byte in content[1:]:
58
+ value = (value << 7) | (byte & 0x7F)
59
+ if not byte & 0x80:
60
+ values.append(str(value))
61
+ value = 0
62
+ if value:
63
+ raise BERError("truncated object identifier")
64
+ return ".".join(values)
65
+
66
+
67
+ def _parse_one(data, offset, limit, depth, nodes):
68
+ if depth > MAX_DEPTH:
69
+ raise BERError("BER nesting is too deep")
70
+ nodes[0] += 1
71
+ if nodes[0] > MAX_NODES:
72
+ raise BERError("BER contains too many elements")
73
+ start = offset
74
+ if offset >= limit:
75
+ raise BERError("truncated BER element")
76
+ first = data[offset]
77
+ offset += 1
78
+ tag_class = first >> 6
79
+ constructed = bool(first & 0x20)
80
+ tag = first & 0x1F
81
+ if tag == 0x1F:
82
+ tag = 0
83
+ while True:
84
+ if offset >= limit:
85
+ raise BERError("truncated high-tag-number element")
86
+ byte = data[offset]
87
+ offset += 1
88
+ tag = (tag << 7) | (byte & 0x7F)
89
+ if not byte & 0x80:
90
+ break
91
+
92
+ if offset >= limit:
93
+ raise BERError("truncated BER length")
94
+ length_byte = data[offset]
95
+ offset += 1
96
+ if length_byte == 0x80:
97
+ if not constructed:
98
+ raise BERError("indefinite length on a primitive element")
99
+ children = []
100
+ while True:
101
+ if offset + 2 > limit:
102
+ raise BERError("missing BER end-of-contents")
103
+ if data[offset] == 0 and data[offset + 1] == 0:
104
+ offset += 2
105
+ return TLV(tag_class, True, tag, b"", children, data[start:offset]), offset
106
+ child, offset = _parse_one(data, offset, limit, depth + 1, nodes)
107
+ children.append(child)
108
+ if length_byte & 0x80:
109
+ count = length_byte & 0x7F
110
+ if count == 0 or count > 8 or offset + count > limit:
111
+ raise BERError("invalid BER length")
112
+ length = int.from_bytes(data[offset : offset + count], "big")
113
+ offset += count
114
+ else:
115
+ length = length_byte
116
+ end = offset + length
117
+ if end > limit:
118
+ raise BERError("truncated BER content")
119
+ if constructed:
120
+ children = []
121
+ child_offset = offset
122
+ while child_offset < end:
123
+ child, child_offset = _parse_one(data, child_offset, end, depth + 1, nodes)
124
+ children.append(child)
125
+ return TLV(tag_class, True, tag, b"", children, data[start:end]), end
126
+ return TLV(tag_class, False, tag, data[offset:end], [], data[start:end]), end
127
+
128
+
129
+ def octets(node: TLV) -> bytes:
130
+ """Concatenate primitive OCTET STRING content, including BER fragments."""
131
+ if not node.constructed:
132
+ return node.content
133
+ result = b""
134
+ for child in node.children:
135
+ if child.tag == 0x04 or child.constructed:
136
+ result += octets(child)
137
+ return result
138
+
139
+
140
+ def first_child(node: TLV, tag: "int | None" = None) -> TLV:
141
+ node.require_constructed()
142
+ for child in node.children:
143
+ if tag is None or child.tag == tag:
144
+ return child
145
+ raise BERError("expected BER child tag %r" % (tag,))
pycert/bundled.py ADDED
@@ -0,0 +1,67 @@
1
+ """The CA certificates shipped with pyCertWiz.
2
+
3
+ These are the same four UK e-Science CA certificates that CertWizard bundled.
4
+ Each one is pinned by the SHA-256 fingerprint of its DER encoding; a resource
5
+ that does not match its pin is treated as tampered with and loading fails. It
6
+ is better to refuse to start than to build (or trust) a chain from the wrong CA.
7
+
8
+ The fingerprints were cross-checked against the IGTF accredited distribution and
9
+ the CA's own publication at https://ca.grid-support.ac.uk/cacerts/.
10
+ """
11
+
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+
15
+ from cryptography import x509
16
+
17
+ from . import certs
18
+ from .errors import ConfigError
19
+
20
+ #: file name -> expected SHA-256 fingerprint of the certificate (lower-case hex)
21
+ PINNED: "dict[str, str]" = {
22
+ # C=UK, O=eScienceRoot, OU=Authority, CN=UK e-Science Root (2007-2027, RSA)
23
+ "escience-root.pem": "76f3a5942e22012d7e10e11b416fb2eea21d8f4583f2d2fc799bd4ced0210323",
24
+ # C=UK, O=eScienceCA, OU=Authority, CN=UK e-Science CA 2B (2011-2027, RSA)
25
+ "escience-2b.pem": "b6d140a765d165d8917bb9941f7ab21ff89c04155a4dce52cad345862c3e686e",
26
+ # C=UK, O=eScienceRoot, OU=Authority, CN=UK e-Science Root 2026 (2026-2046, ECDSA)
27
+ "escience-root-2026.pem": "565c16e7d6ff0f602aefca2a585634c190d7d9da411c7750d79cac6b351ffffa",
28
+ # C=UK, O=eScienceCA, OU=Authority, CN=UK e-Science CA 3B (2026-2046, ECDSA)
29
+ "escience-3b.pem": "16383860bc6e61488ffb945e961963967c4fe8d73d2b2e9641d0b535cca4b845",
30
+ }
31
+
32
+
33
+ def data_dir() -> Path:
34
+ """Filesystem path of the bundled certificate directory (for copying)."""
35
+ return Path(__file__).resolve().parent / "data" / "ca"
36
+
37
+
38
+ def path_for(name: str) -> Path:
39
+ return data_dir() / name
40
+
41
+
42
+ @lru_cache(maxsize=1)
43
+ def certificates() -> "tuple[x509.Certificate, ...]":
44
+ """Load and pin-check every bundled CA certificate."""
45
+ result = []
46
+ for name, expected in PINNED.items():
47
+ path = path_for(name)
48
+ if not path.is_file():
49
+ raise ConfigError(f"bundled CA certificate is missing: {path}")
50
+ try:
51
+ cert = certs.load_certificate(path.read_bytes())
52
+ except Exception as exc: # noqa: BLE001
53
+ raise ConfigError(f"cannot parse bundled CA certificate {name}: {exc}") from exc
54
+ actual = certs.fingerprint(cert)
55
+ if actual != expected:
56
+ raise ConfigError(
57
+ f"bundled CA certificate {name} does not match its pinned SHA-256 fingerprint "
58
+ f"(expected {expected}, got {actual}); refusing to use it"
59
+ )
60
+ result.append(cert)
61
+ return tuple(result)
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def pem_bundle() -> str:
66
+ """All bundled certificates as one PEM string (ready for ssl context)."""
67
+ return "".join(certs.cert_pem(c) for c in certificates())