certgraph 0.1.0__tar.gz → 0.2.0__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.
- {certgraph-0.1.0 → certgraph-0.2.0}/PKG-INFO +1 -1
- {certgraph-0.1.0 → certgraph-0.2.0}/pyproject.toml +1 -1
- certgraph-0.2.0/src/certgraph/certgraph.py +217 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/src/certgraph.egg-info/PKG-INFO +1 -1
- certgraph-0.2.0/src/certgraph.egg-info/SOURCES.txt +19 -0
- certgraph-0.2.0/tests/test_clear.py +21 -0
- certgraph-0.2.0/tests/test_export_dot.py +9 -0
- certgraph-0.2.0/tests/test_fingerprint_search.py +28 -0
- certgraph-0.2.0/tests/test_get_certificate.py +20 -0
- certgraph-0.2.0/tests/test_get_child_fingerprints.py +38 -0
- certgraph-0.2.0/tests/test_get_issuer_fingerprint.py +35 -0
- certgraph-0.2.0/tests/test_graph.py +36 -0
- certgraph-0.2.0/tests/test_import_certificates.py +39 -0
- certgraph-0.2.0/tests/test_remove_certificate.py +41 -0
- certgraph-0.1.0/src/certgraph/certgraph.py +0 -109
- certgraph-0.1.0/src/certgraph.egg-info/SOURCES.txt +0 -10
- {certgraph-0.1.0 → certgraph-0.2.0}/LICENSE +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/README.md +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/setup.cfg +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/src/certgraph/__init__.py +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/src/certgraph.egg-info/dependency_links.txt +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/src/certgraph.egg-info/requires.txt +0 -0
- {certgraph-0.1.0 → certgraph-0.2.0}/src/certgraph.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from cryptography import x509
|
|
2
|
+
from cryptography.hazmat.primitives import hashes
|
|
3
|
+
import networkx as nx
|
|
4
|
+
from rapidfuzz import fuzz
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class certgraph:
|
|
8
|
+
"""Class for creating and exploring directed graphs of X509 certificates."""
|
|
9
|
+
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
"""Initialise an empty certgraph object."""
|
|
12
|
+
self._certlist: set[x509.Certificate] = []
|
|
13
|
+
self._graph = nx.DiGraph()
|
|
14
|
+
|
|
15
|
+
def import_certificates(
|
|
16
|
+
self,
|
|
17
|
+
certificates: (
|
|
18
|
+
x509.Certificate | str | bytes | list[x509.Certificate | str | bytes]
|
|
19
|
+
),
|
|
20
|
+
) -> certgraph:
|
|
21
|
+
"""
|
|
22
|
+
Import one or more certificates and regenerate the certificate digraph.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
certificates: Certificates to import, either individual or a list. x509.Certificate objects are directly imported, str is assumed to be PEM encoded, bytes are assumed to be DER encoded.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Returns self to allow method chaining.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
TypeError: Certificate type is not supported (i.e. not x509.Certificate, str or bytes)
|
|
32
|
+
"""
|
|
33
|
+
new_certs: set[x509.Certificate] = []
|
|
34
|
+
|
|
35
|
+
# If there's only a non-list object being handing in, turn it into an array to keep the same iteration code
|
|
36
|
+
for i, cert in enumerate(
|
|
37
|
+
certificates if isinstance(certificates, list) else [certificates]
|
|
38
|
+
):
|
|
39
|
+
if isinstance(cert, x509.Certificate):
|
|
40
|
+
# No conversion neccessary
|
|
41
|
+
new_certs.append(cert)
|
|
42
|
+
elif isinstance(cert, str):
|
|
43
|
+
# PEM encoded
|
|
44
|
+
pem_certs = x509.load_pem_x509_certificates(cert.encode("utf-8"))
|
|
45
|
+
new_certs.extend(pem_certs)
|
|
46
|
+
elif isinstance(cert, bytes):
|
|
47
|
+
# DER encoded
|
|
48
|
+
der_cert = x509.load_der_x509_certificate(cert)
|
|
49
|
+
new_certs.append(der_cert)
|
|
50
|
+
else:
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"Cannot import certificate {i} from data type {type(cert)}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self._certlist.extend(new_certs)
|
|
56
|
+
self._graph = self._generate_graph(self._certlist)
|
|
57
|
+
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def _generate_graph(self, certificates: set[x509.Certificate]) -> nx.DiGraph:
|
|
61
|
+
"""Generate a networkx digraph from the provided list of certificates."""
|
|
62
|
+
G = nx.DiGraph()
|
|
63
|
+
|
|
64
|
+
# Pass 1: add nodes and build a subject_dn -> fingerprint index
|
|
65
|
+
subject_index: dict[str, list[str]] = {}
|
|
66
|
+
for cert in certificates:
|
|
67
|
+
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
|
68
|
+
G.add_node(fingerprint, certificate=cert)
|
|
69
|
+
|
|
70
|
+
subject_dn = cert.subject.rfc4514_string()
|
|
71
|
+
subject_index.setdefault(subject_dn, []).append(fingerprint)
|
|
72
|
+
|
|
73
|
+
# Pass 2: add edges using the index
|
|
74
|
+
for fingerprint, data in G.nodes(data=True):
|
|
75
|
+
issuer_dn = data["certificate"].issuer.rfc4514_string()
|
|
76
|
+
|
|
77
|
+
for issuer_fingerprint in subject_index.get(issuer_dn, []):
|
|
78
|
+
G.add_edge(issuer_fingerprint, fingerprint)
|
|
79
|
+
|
|
80
|
+
return G
|
|
81
|
+
|
|
82
|
+
def report_fingerprint_edges(self) -> list[str]:
|
|
83
|
+
"""
|
|
84
|
+
Get the list of all edges in the form 'fingerprint1 -> fingerprint2'. Only contains the first 8 characters of each fingerprint, because this is mostly for debugging from a terminal without having to generate a complete graph image.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of all edges in the current certificate digraph in terms of fingerprints.
|
|
88
|
+
"""
|
|
89
|
+
return [f"{edge[0][:8]} -> {edge[1][:8]}" for edge in self._graph.edges()]
|
|
90
|
+
|
|
91
|
+
def export_dot(self, format: str = "svg") -> str:
|
|
92
|
+
"""Function to export the current certificate digraph in the dot language. NOTE: Still in progress."""
|
|
93
|
+
allowed_types = ["svg", "png"]
|
|
94
|
+
|
|
95
|
+
if format not in allowed_types:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Cannot export dot graph of type {format} - must be one of {allowed_types}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
dot_graph = nx.DiGraph()
|
|
101
|
+
|
|
102
|
+
for fingerprint, data in self._graph.nodes(data=True):
|
|
103
|
+
label = data["certificate"].subject.rfc4514_string()
|
|
104
|
+
dot_graph.add_node(fingerprint, label=label)
|
|
105
|
+
dot_graph.add_edges_from(self._graph.edges())
|
|
106
|
+
|
|
107
|
+
nx.drawing.nx_pydot.write_dot(dot_graph, "./test_out.dot")
|
|
108
|
+
|
|
109
|
+
return "dot"
|
|
110
|
+
|
|
111
|
+
def clear(self) -> certgraph:
|
|
112
|
+
"""
|
|
113
|
+
Remove all certificates imported and clear the digraph.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Returns self to allow method chaining.
|
|
117
|
+
"""
|
|
118
|
+
self._certlist.clear()
|
|
119
|
+
self._graph.clear()
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def fingerprint_from_distinguished_name(self, dn: str, cutoff: int = 0) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Get the fingerprint of the imported certificate with the best fuzzy-search match between the requested distinguised name and the rfc4514 string of each imported certificate.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
dn: The distinguished name to use as the basis for the fuzzy-search.
|
|
128
|
+
cutoff: Cutoff value for matching, from 0-100. 0 will always return the closest match (even if poor), 100 will only ever return an exact match.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The fingerprint of the certificate with the closes matching distinguished name to the one requested. Can also return None if there are no certificates above the cutoff threshold.
|
|
132
|
+
"""
|
|
133
|
+
if (cutoff < 0) or (cutoff > 100):
|
|
134
|
+
raise ValueError("cutoff must be between 0-100")
|
|
135
|
+
|
|
136
|
+
# Evaluate the nodes
|
|
137
|
+
nodes: list[tuple[str, dict]] = list(self._graph.nodes(data=True))
|
|
138
|
+
|
|
139
|
+
# Sort using a fuzzy-search
|
|
140
|
+
ranked = sorted(
|
|
141
|
+
nodes,
|
|
142
|
+
key=lambda t: fuzz.ratio(
|
|
143
|
+
dn, t[1]["certificate"].subject.rfc4514_string(), score_cutoff=cutoff
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if not ranked:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
return ranked[-1][0]
|
|
151
|
+
|
|
152
|
+
def get_certificate(self, fingerprint: str) -> x509.Certificate | None:
|
|
153
|
+
"""
|
|
154
|
+
Get an imported ceritficate based on the fingerprint.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
fingerprint: Fingerprint of the certificate to get. Use fingerprint_from_distinguished_name() to resolve the fingerprint from a DN.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Certificate that matches the provided fingerprint or None if it doesn't exist.
|
|
161
|
+
"""
|
|
162
|
+
if fingerprint not in self._graph.nodes:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
return self._graph.nodes[fingerprint]["certificate"]
|
|
166
|
+
|
|
167
|
+
def get_issuer_fingerprint(self, fingerprint: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Get the fingerprint of the issuer certificate.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
fingerprint: Fingerprint of the certificate to get the issuer of.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The fingerprint of the issuing certificate or None if not present in the digraph.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If certificate with the supplied fingerprint isn't in the digraph.
|
|
179
|
+
"""
|
|
180
|
+
if fingerprint not in self._graph.nodes:
|
|
181
|
+
raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
|
|
182
|
+
|
|
183
|
+
return next(self._graph.predecessors(fingerprint), None)
|
|
184
|
+
|
|
185
|
+
def get_child_fingerprints(self, fingerprint: str) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Get the fingerprints of any certificates issued by the certificate with the provided fingerprint.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
fingerprint: Fingerprint of the certificate to get the child certificates of.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The list of fingerprints belonging to child certificates.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If certificate with the supplied fingerprint isn't in the digraph.
|
|
197
|
+
"""
|
|
198
|
+
if fingerprint not in self._graph.nodes:
|
|
199
|
+
raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
|
|
200
|
+
|
|
201
|
+
return list(self._graph.successors(fingerprint))
|
|
202
|
+
|
|
203
|
+
def remove_certificate(self, fingerprint: str) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Remove an imported ceritficate based on the fingerprint and update the certificate digraph.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
fingerprint: Fingerprint of the certificate to remove. Use fingerprint_from_distinguished_name() to resolve the fingerprint from a DN.
|
|
209
|
+
"""
|
|
210
|
+
if fingerprint in self._graph.nodes:
|
|
211
|
+
cert = self._graph.nodes[fingerprint]["certificate"]
|
|
212
|
+
|
|
213
|
+
self._graph.remove_node(fingerprint)
|
|
214
|
+
|
|
215
|
+
if cert in self._certlist:
|
|
216
|
+
self._certlist.remove(cert)
|
|
217
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/certgraph/__init__.py
|
|
5
|
+
src/certgraph/certgraph.py
|
|
6
|
+
src/certgraph.egg-info/PKG-INFO
|
|
7
|
+
src/certgraph.egg-info/SOURCES.txt
|
|
8
|
+
src/certgraph.egg-info/dependency_links.txt
|
|
9
|
+
src/certgraph.egg-info/requires.txt
|
|
10
|
+
src/certgraph.egg-info/top_level.txt
|
|
11
|
+
tests/test_clear.py
|
|
12
|
+
tests/test_export_dot.py
|
|
13
|
+
tests/test_fingerprint_search.py
|
|
14
|
+
tests/test_get_certificate.py
|
|
15
|
+
tests/test_get_child_fingerprints.py
|
|
16
|
+
tests/test_get_issuer_fingerprint.py
|
|
17
|
+
tests/test_graph.py
|
|
18
|
+
tests/test_import_certificates.py
|
|
19
|
+
tests/test_remove_certificate.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
def test_clear_removes_edges(graph, chain_certs):
|
|
2
|
+
graph.import_certificates(chain_certs)
|
|
3
|
+
assert graph.report_fingerprint_edges()
|
|
4
|
+
|
|
5
|
+
graph.clear()
|
|
6
|
+
|
|
7
|
+
assert graph.report_fingerprint_edges() == []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_clear_returns_self(graph):
|
|
11
|
+
assert graph.clear() is graph
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_clear_resets_dn_search(graph, self_signed_cert):
|
|
15
|
+
graph.import_certificates(self_signed_cert)
|
|
16
|
+
dn = self_signed_cert.subject.rfc4514_string()
|
|
17
|
+
assert graph.fingerprint_from_distinguished_name(dn) is not None
|
|
18
|
+
|
|
19
|
+
graph.clear()
|
|
20
|
+
|
|
21
|
+
assert graph.fingerprint_from_distinguished_name(dn) is None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
# export_dot()'s actual output generation is still rough and not covered here.
|
|
4
|
+
# Only the argument validation, which happens before anything is written, is tested.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_export_dot_rejects_invalid_format(graph):
|
|
8
|
+
with pytest.raises(ValueError):
|
|
9
|
+
graph.export_dot(format="jpeg")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from cryptography.hazmat.primitives import hashes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_returns_none_for_empty_graph(graph):
|
|
5
|
+
assert graph.fingerprint_from_distinguished_name("CN=whatever") is None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_exact_match_returns_correct_fingerprint(graph, chain_certs):
|
|
9
|
+
graph.import_certificates(chain_certs)
|
|
10
|
+
target = chain_certs[1]
|
|
11
|
+
dn = target.subject.rfc4514_string()
|
|
12
|
+
expected_fp = target.fingerprint(hashes.SHA256()).hex()
|
|
13
|
+
|
|
14
|
+
result = graph.fingerprint_from_distinguished_name(dn)
|
|
15
|
+
|
|
16
|
+
assert result == expected_fp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_fuzzy_match_finds_closest_dn(graph, chain_certs):
|
|
20
|
+
graph.import_certificates(chain_certs)
|
|
21
|
+
target = chain_certs[0]
|
|
22
|
+
dn = target.subject.rfc4514_string()
|
|
23
|
+
typo_dn = dn[:-1] # drop a character to force a fuzzy, non-exact match
|
|
24
|
+
expected_fp = target.fingerprint(hashes.SHA256()).hex()
|
|
25
|
+
|
|
26
|
+
result = graph.fingerprint_from_distinguished_name(typo_dn)
|
|
27
|
+
|
|
28
|
+
assert result == expected_fp
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from cryptography.hazmat.primitives import hashes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_returns_none_for_unknown_fingerprint(graph):
|
|
5
|
+
assert graph.get_certificate("does-not-exist") is None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_returns_certificate_for_known_fingerprint(graph, self_signed_cert):
|
|
9
|
+
graph.import_certificates(self_signed_cert)
|
|
10
|
+
fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
|
|
11
|
+
|
|
12
|
+
assert graph.get_certificate(fingerprint) == self_signed_cert
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_returns_correct_certificate_from_chain(graph, chain_certs):
|
|
16
|
+
graph.import_certificates(chain_certs)
|
|
17
|
+
target = chain_certs[1]
|
|
18
|
+
fingerprint = target.fingerprint(hashes.SHA256()).hex()
|
|
19
|
+
|
|
20
|
+
assert graph.get_certificate(fingerprint) == target
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cryptography.hazmat.primitives import hashes
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_raises_for_unknown_fingerprint(graph):
|
|
6
|
+
with pytest.raises(ValueError):
|
|
7
|
+
graph.get_child_fingerprints("does-not-exist")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_leaf_certificate_has_no_children(graph, chain_certs):
|
|
11
|
+
graph.import_certificates(chain_certs)
|
|
12
|
+
leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
|
|
13
|
+
|
|
14
|
+
assert graph.get_child_fingerprints(leaf_fp) == []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_self_signed_certificate_is_its_own_child(graph, self_signed_cert):
|
|
18
|
+
graph.import_certificates(self_signed_cert)
|
|
19
|
+
fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
|
|
20
|
+
|
|
21
|
+
assert graph.get_child_fingerprints(fingerprint) == [fingerprint]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_root_returns_itself_and_issued_certificate(graph, chain_certs):
|
|
25
|
+
graph.import_certificates(chain_certs)
|
|
26
|
+
root_fp = chain_certs[0].fingerprint(hashes.SHA256()).hex()
|
|
27
|
+
intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
|
|
28
|
+
|
|
29
|
+
# root has a self-loop plus the certificate it issued
|
|
30
|
+
assert set(graph.get_child_fingerprints(root_fp)) == {root_fp, intermediate_fp}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_intermediate_returns_only_leaf_it_issued(graph, chain_certs):
|
|
34
|
+
graph.import_certificates(chain_certs)
|
|
35
|
+
intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
|
|
36
|
+
leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
|
|
37
|
+
|
|
38
|
+
assert graph.get_child_fingerprints(intermediate_fp) == [leaf_fp]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cryptography.hazmat.primitives import hashes
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_raises_for_unknown_fingerprint(graph):
|
|
6
|
+
with pytest.raises(ValueError):
|
|
7
|
+
graph.get_issuer_fingerprint("does-not-exist")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_self_signed_certificate_is_its_own_issuer(graph, self_signed_cert):
|
|
11
|
+
graph.import_certificates(self_signed_cert)
|
|
12
|
+
fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
|
|
13
|
+
|
|
14
|
+
assert graph.get_issuer_fingerprint(fingerprint) == fingerprint
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_leaf_returns_direct_issuer_fingerprint(graph, chain_certs):
|
|
18
|
+
graph.import_certificates(chain_certs)
|
|
19
|
+
root_fp = chain_certs[0].fingerprint(hashes.SHA256()).hex()
|
|
20
|
+
intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
|
|
21
|
+
leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
|
|
22
|
+
|
|
23
|
+
assert graph.get_issuer_fingerprint(intermediate_fp) == root_fp
|
|
24
|
+
assert graph.get_issuer_fingerprint(leaf_fp) == intermediate_fp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_unrelated_self_signed_certs_do_not_share_issuer(graph, make_chain):
|
|
28
|
+
cert_a = make_chain(1, cns=["alpha-root"])[0]
|
|
29
|
+
cert_b = make_chain(1, cns=["beta-root"])[0]
|
|
30
|
+
graph.import_certificates([cert_a, cert_b])
|
|
31
|
+
fp_a = cert_a.fingerprint(hashes.SHA256()).hex()
|
|
32
|
+
fp_b = cert_b.fingerprint(hashes.SHA256()).hex()
|
|
33
|
+
|
|
34
|
+
assert graph.get_issuer_fingerprint(fp_a) == fp_a
|
|
35
|
+
assert graph.get_issuer_fingerprint(fp_b) == fp_b
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
def test_empty_graph_has_no_edges(graph):
|
|
2
|
+
assert graph.report_fingerprint_edges() == []
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_self_signed_certificate_produces_self_loop(graph, self_signed_cert):
|
|
6
|
+
graph.import_certificates(self_signed_cert)
|
|
7
|
+
edges = graph.report_fingerprint_edges()
|
|
8
|
+
assert len(edges) == 1
|
|
9
|
+
src, _, dst = edges[0].partition(" -> ")
|
|
10
|
+
assert src == dst
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_chain_produces_expected_number_of_edges(graph, chain_certs):
|
|
14
|
+
graph.import_certificates(chain_certs)
|
|
15
|
+
# one self-loop for the root, plus one edge linking each subsequent cert to its issuer
|
|
16
|
+
assert len(graph.report_fingerprint_edges()) == len(chain_certs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_unrelated_self_signed_certs_do_not_link(graph, make_chain):
|
|
20
|
+
cert_a = make_chain(1, cns=["alpha-root"])[0]
|
|
21
|
+
cert_b = make_chain(1, cns=["beta-root"])[0]
|
|
22
|
+
graph.import_certificates([cert_a, cert_b])
|
|
23
|
+
|
|
24
|
+
edges = graph.report_fingerprint_edges()
|
|
25
|
+
assert len(edges) == 2
|
|
26
|
+
for edge in edges:
|
|
27
|
+
src, _, dst = edge.partition(" -> ")
|
|
28
|
+
assert src == dst
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_edge_report_format_uses_truncated_fingerprints(graph, self_signed_cert):
|
|
32
|
+
graph.import_certificates(self_signed_cert)
|
|
33
|
+
edge = graph.report_fingerprint_edges()[0]
|
|
34
|
+
src, _, dst = edge.partition(" -> ")
|
|
35
|
+
assert len(src) == 8
|
|
36
|
+
assert len(dst) == 8
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cryptography.hazmat.primitives import serialization
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_import_single_certificate_object(graph, self_signed_cert):
|
|
6
|
+
graph.import_certificates(self_signed_cert)
|
|
7
|
+
assert len(graph.report_fingerprint_edges()) == 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_import_certificate_list(graph, chain_certs):
|
|
11
|
+
graph.import_certificates(chain_certs)
|
|
12
|
+
assert len(graph.report_fingerprint_edges()) == len(chain_certs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_import_pem_string(graph, chain_pem, chain_certs):
|
|
16
|
+
graph.import_certificates(chain_pem)
|
|
17
|
+
assert len(graph.report_fingerprint_edges()) == len(chain_certs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_import_der_bytes(graph, self_signed_cert):
|
|
21
|
+
der = self_signed_cert.public_bytes(serialization.Encoding.DER)
|
|
22
|
+
graph.import_certificates(der)
|
|
23
|
+
assert len(graph.report_fingerprint_edges()) == 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_import_invalid_type_raises_typeerror(graph):
|
|
27
|
+
with pytest.raises(TypeError):
|
|
28
|
+
graph.import_certificates(12345)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_import_returns_self_for_chaining(graph, self_signed_cert):
|
|
32
|
+
result = graph.import_certificates(self_signed_cert)
|
|
33
|
+
assert result is graph
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_import_accumulates_across_multiple_calls(graph, chain_certs):
|
|
37
|
+
graph.import_certificates(chain_certs[0])
|
|
38
|
+
graph.import_certificates(chain_certs[1:])
|
|
39
|
+
assert len(graph.report_fingerprint_edges()) == len(chain_certs)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from cryptography.hazmat.primitives import hashes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_removes_node_from_graph(graph, self_signed_cert):
|
|
5
|
+
graph.import_certificates(self_signed_cert)
|
|
6
|
+
fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
|
|
7
|
+
|
|
8
|
+
graph.remove_certificate(fingerprint)
|
|
9
|
+
|
|
10
|
+
assert graph.get_certificate(fingerprint) is None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_removes_certificate_from_certlist(graph, self_signed_cert):
|
|
14
|
+
graph.import_certificates(self_signed_cert)
|
|
15
|
+
|
|
16
|
+
fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
|
|
17
|
+
graph.remove_certificate(fingerprint)
|
|
18
|
+
|
|
19
|
+
assert self_signed_cert not in graph._certlist
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_unknown_fingerprint_is_a_no_op(graph, chain_certs):
|
|
23
|
+
graph.import_certificates(chain_certs)
|
|
24
|
+
edges_before = graph.report_fingerprint_edges()
|
|
25
|
+
|
|
26
|
+
graph.remove_certificate("does-not-exist")
|
|
27
|
+
|
|
28
|
+
assert graph.report_fingerprint_edges() == edges_before
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_removing_intermediate_drops_its_edges(graph, chain_certs):
|
|
32
|
+
graph.import_certificates(chain_certs)
|
|
33
|
+
intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
|
|
34
|
+
leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
|
|
35
|
+
|
|
36
|
+
graph.remove_certificate(intermediate_fp)
|
|
37
|
+
|
|
38
|
+
remaining = graph.report_fingerprint_edges()
|
|
39
|
+
assert all(intermediate_fp[:8] not in edge for edge in remaining)
|
|
40
|
+
# the leaf certificate is still present, just orphaned
|
|
41
|
+
assert graph.get_certificate(leaf_fp) == chain_certs[2]
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
from cryptography import x509
|
|
2
|
-
from cryptography.hazmat.primitives import hashes
|
|
3
|
-
import networkx as nx
|
|
4
|
-
from rapidfuzz import fuzz
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class certgraph:
|
|
8
|
-
def __init__(self) -> None:
|
|
9
|
-
self._certlist: set[x509.Certificate] = []
|
|
10
|
-
self._graph = nx.DiGraph()
|
|
11
|
-
|
|
12
|
-
def import_certificates(
|
|
13
|
-
self,
|
|
14
|
-
certificates: (
|
|
15
|
-
x509.Certificate | str | bytes | list[x509.Certificate | str | bytes]
|
|
16
|
-
),
|
|
17
|
-
pem_encoding: str = "utf-8",
|
|
18
|
-
) -> certgraph:
|
|
19
|
-
new_certs: set[x509.Certificate] = []
|
|
20
|
-
|
|
21
|
-
# If there's only a non-list object being handing in, turn it into an array to keep the same iteration code
|
|
22
|
-
for i, cert in enumerate(
|
|
23
|
-
certificates if isinstance(certificates, list) else [certificates]
|
|
24
|
-
):
|
|
25
|
-
if isinstance(cert, x509.Certificate):
|
|
26
|
-
# No conversion neccessary
|
|
27
|
-
new_certs.append(cert)
|
|
28
|
-
elif isinstance(cert, str):
|
|
29
|
-
# PEM encoded
|
|
30
|
-
pem_certs = x509.load_pem_x509_certificates(cert.encode(pem_encoding))
|
|
31
|
-
new_certs.extend(pem_certs)
|
|
32
|
-
elif isinstance(cert, bytes):
|
|
33
|
-
# DER encoded
|
|
34
|
-
der_cert = x509.load_der_x509_certificate(cert)
|
|
35
|
-
new_certs.append(der_cert)
|
|
36
|
-
else:
|
|
37
|
-
raise TypeError(
|
|
38
|
-
f"Cannot import certificate {i} from data type {type(cert)}"
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
self._certlist.extend(new_certs)
|
|
42
|
-
self._graph = self._generate_graph(self._certlist)
|
|
43
|
-
|
|
44
|
-
return self
|
|
45
|
-
|
|
46
|
-
def _generate_graph(self, certificates: set[x509.Certificate]) -> nx.DiGraph:
|
|
47
|
-
G = nx.DiGraph()
|
|
48
|
-
|
|
49
|
-
# Pass 1: add nodes and build a subject_dn -> fingerprint index
|
|
50
|
-
subject_index: dict[str, list[str]] = {}
|
|
51
|
-
for cert in certificates:
|
|
52
|
-
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
|
53
|
-
G.add_node(fingerprint, certificate=cert)
|
|
54
|
-
|
|
55
|
-
subject_dn = cert.subject.rfc4514_string()
|
|
56
|
-
subject_index.setdefault(subject_dn, []).append(fingerprint)
|
|
57
|
-
|
|
58
|
-
# Pass 2: add edges using the index
|
|
59
|
-
for fingerprint, data in G.nodes(data=True):
|
|
60
|
-
issuer_dn = data["certificate"].issuer.rfc4514_string()
|
|
61
|
-
|
|
62
|
-
for issuer_fingerprint in subject_index.get(issuer_dn, []):
|
|
63
|
-
G.add_edge(issuer_fingerprint, fingerprint)
|
|
64
|
-
|
|
65
|
-
return G
|
|
66
|
-
|
|
67
|
-
def report_fingerprint_edges(self) -> list[str]:
|
|
68
|
-
return [f"{edge[0][:8]} -> {edge[1][:8]}" for edge in self._graph.edges()]
|
|
69
|
-
|
|
70
|
-
def export_dot(self, format: str = "svg") -> str:
|
|
71
|
-
allowed_types = ["svg", "png"]
|
|
72
|
-
|
|
73
|
-
if format not in allowed_types:
|
|
74
|
-
raise ValueError(
|
|
75
|
-
f"Cannot export dot graph of type {format} - must be one of {allowed_types}"
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
dot_graph = nx.DiGraph()
|
|
79
|
-
|
|
80
|
-
for fingerprint, data in self._graph.nodes(data=True):
|
|
81
|
-
label = data["certificate"].subject.rfc4514_string()
|
|
82
|
-
dot_graph.add_node(fingerprint, label=label)
|
|
83
|
-
dot_graph.add_edges_from(self._graph.edges())
|
|
84
|
-
|
|
85
|
-
nx.drawing.nx_pydot.write_dot(dot_graph, "./test_out.dot")
|
|
86
|
-
|
|
87
|
-
return "dot"
|
|
88
|
-
|
|
89
|
-
def clear(self) -> certgraph:
|
|
90
|
-
self._certlist.clear()
|
|
91
|
-
self._graph.clear()
|
|
92
|
-
return self
|
|
93
|
-
|
|
94
|
-
def fingerprint_from_distinguished_name(self, dn: str, cutoff: int = 0) -> str:
|
|
95
|
-
# Evaluate the nodes
|
|
96
|
-
nodes: list[tuple[str, dict]] = list(self._graph.nodes(data=True))
|
|
97
|
-
|
|
98
|
-
# Sort using a fuzzy-search
|
|
99
|
-
ranked = sorted(
|
|
100
|
-
nodes,
|
|
101
|
-
key=lambda t: fuzz.ratio(
|
|
102
|
-
dn, t[1]["certificate"].subject.rfc4514_string(), score_cutoff=cutoff
|
|
103
|
-
)
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
if not ranked:
|
|
107
|
-
return None
|
|
108
|
-
|
|
109
|
-
return ranked[-1][0]
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
README.md
|
|
3
|
-
pyproject.toml
|
|
4
|
-
src/certgraph/__init__.py
|
|
5
|
-
src/certgraph/certgraph.py
|
|
6
|
-
src/certgraph.egg-info/PKG-INFO
|
|
7
|
-
src/certgraph.egg-info/SOURCES.txt
|
|
8
|
-
src/certgraph.egg-info/dependency_links.txt
|
|
9
|
-
src/certgraph.egg-info/requires.txt
|
|
10
|
-
src/certgraph.egg-info/top_level.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|