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.
- pycert/__init__.py +15 -0
- pycert/__main__.py +6 -0
- pycert/asn1.py +135 -0
- pycert/ber.py +145 -0
- pycert/bundled.py +67 -0
- pycert/bundles.py +723 -0
- pycert/ca.py +693 -0
- pycert/certs.py +674 -0
- pycert/chain.py +370 -0
- pycert/cli.py +2299 -0
- pycert/config.py +1623 -0
- pycert/csr.py +192 -0
- pycert/data/__init__.py +0 -0
- pycert/data/ca/__init__.py +0 -0
- pycert/data/ca/escience-2b.pem +23 -0
- pycert/data/ca/escience-3b.pem +16 -0
- pycert/data/ca/escience-root-2026.pem +15 -0
- pycert/data/ca/escience-root.pem +21 -0
- pycert/deploy.py +481 -0
- pycert/diagnostics.py +337 -0
- pycert/errors.py +59 -0
- pycert/exporter.py +372 -0
- pycert/gui/__init__.py +107 -0
- pycert/gui/app.py +1776 -0
- pycert/gui/bundle.py +363 -0
- pycert/gui/dialogs.py +1093 -0
- pycert/gui/icons.py +144 -0
- pycert/gui/models.py +357 -0
- pycert/gui/settings.py +475 -0
- pycert/gui/setup.py +136 -0
- pycert/gui/theme.py +281 -0
- pycert/gui/tray.py +164 -0
- pycert/gui/viewer.py +350 -0
- pycert/gui/widgets.py +727 -0
- pycert/gui/wizard.py +630 -0
- pycert/gui/workers.py +232 -0
- pycert/keystore.py +862 -0
- pycert/monitor.py +256 -0
- pycert/p12.py +275 -0
- pycert/pkcs12_writer.py +343 -0
- pycert/providers/__init__.py +98 -0
- pycert/providers/acme.py +1204 -0
- pycert/providers/base.py +270 -0
- pycert/providers/ukca.py +399 -0
- pycert/proxy.py +254 -0
- pycert/secrets_store.py +449 -0
- pycert/store.py +614 -0
- pycert/transport.py +843 -0
- pycert/util.py +235 -0
- pycert/workflow.py +1702 -0
- pycertwiz-2.0.1.dist-info/METADATA +924 -0
- pycertwiz-2.0.1.dist-info/RECORD +56 -0
- pycertwiz-2.0.1.dist-info/WHEEL +5 -0
- pycertwiz-2.0.1.dist-info/entry_points.txt +7 -0
- pycertwiz-2.0.1.dist-info/licenses/LICENSE +339 -0
- 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
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())
|