Skip to content

Commit 48be666

Browse files
committed
feat(RELEASE-2031): advanced certificate check script
Supports expiration, key-cert mismatch check, revocation check Assisted-by: Claude Signed-off-by: Jindrich Luza <jluza@redhat.com>
1 parent 65cf371 commit 48be666

2 files changed

Lines changed: 821 additions & 0 deletions

File tree

utils/check-cert.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
#!/usr/bin/env python3
2+
"""Certificate Checker.
3+
4+
This script checks the validity of a certificate by performing the following checks:
5+
1. Expiration Check: Verifies if the certificate is currently valid by checking:
6+
- The current time is after the 'not valid before' date
7+
- The current time is before the 'not valid after' date
8+
This check is performed every time the script is run.
9+
2. Certificate-Key Match: If a private key is provided, the script checks
10+
if it matches the public key in the certificate.
11+
3. OCSP Revocation Check: If an issuer certificate is provided, the script performs
12+
an OCSP check to determine if the certificate has been revoked.
13+
14+
The script produces the following JSON to stdout and returns 0 if all checks are
15+
successful; otherwise, it returns 1.
16+
17+
{'expired': <boolean>,
18+
'cert_key_match': <boolean>,
19+
'serial_number': <serial_number>,
20+
'issuer': <issuer>,
21+
'subject': <subject>,
22+
'not_valid_before': <YYYY-MM-DDThh:mm:ss+tz:tz>,
23+
'not_valid_after': <YYYY-MM-DDThh:mm:ss+tz:tz>,
24+
'cert_ocsp_details': {'validation_status': 'OCSPResponseStatus.<STATUS>',
25+
'cert_status': 'OCSPCertStatus.<STATUS>',
26+
'this_update': '<YYYY-MM-DDThh:mm:ss+tz:tz>',
27+
'next_update': <YYYY-MM-DDThh:mm:ss+tz:tz> or null,
28+
'revocation_time': <YYYY-MM-DDThh:mm:ss+tz:tz> or null,
29+
'revocation_reason': <reason>}}
30+
"""
31+
32+
import argparse
33+
import datetime
34+
import json
35+
import requests
36+
import sys
37+
import traceback
38+
39+
from cryptography import x509
40+
from cryptography.hazmat.primitives import serialization, hashes
41+
from cryptography.x509 import ocsp
42+
43+
44+
def load_cert(path):
45+
"""Load a certificate from the given path.
46+
47+
Args:
48+
path (str): The file path to the certificate (PEM format).
49+
50+
Returns:
51+
cryptography.x509.Certificate: The loaded certificate object.
52+
53+
"""
54+
with open(path, "rb") as f:
55+
return x509.load_pem_x509_certificate(f.read())
56+
57+
58+
def cert_info(cert_path, cert_key_path=None, issuer_path=None, ocsp_timeout=10):
59+
"""Gather information about a certificate.
60+
61+
including expiration, key match, and OCSP status.
62+
63+
Args:
64+
cert_path (str): Path to the certificate file (PEM format).
65+
cert_key_path (str, optional): Path to the private key file (PEM format).
66+
Defaults to None.
67+
issuer_path (str, optional): Path to the issuer certificate file (PEM format).
68+
Defaults to None.
69+
ocsp_timeout (int, optional): Timeout for OCSP request in seconds.
70+
Defaults to 10.
71+
72+
Returns:
73+
tuple: A tuple containing:
74+
- dict: A dictionary with certificate details and check results.
75+
- bool: True if all checks pass, False otherwise.
76+
77+
"""
78+
try:
79+
# 1. Load and Validate Certificate
80+
cert = load_cert(cert_path)
81+
82+
cert_key_match = None
83+
cert_status_details = {}
84+
expired = cert.not_valid_after_utc < datetime.datetime.now(tz=datetime.timezone.utc)
85+
already_valid = cert.not_valid_before_utc < datetime.datetime.now(
86+
tz=datetime.timezone.utc
87+
)
88+
89+
# 2. Load and Validate Private Key
90+
if cert_key_path:
91+
with open(cert_key_path, "rb") as f:
92+
key_data = f.read()
93+
# If your key has a password, provide it in 'password='
94+
private_key = serialization.load_pem_private_key(key_data, password=None)
95+
# 3. Check if they match
96+
# We compare the public key derived from the cert vs the one from the private key
97+
cert_pub_key = cert.public_key().public_bytes(
98+
encoding=serialization.Encoding.PEM,
99+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
100+
)
101+
key_pub_key = private_key.public_key().public_bytes(
102+
encoding=serialization.Encoding.PEM,
103+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
104+
)
105+
if cert_pub_key == key_pub_key:
106+
cert_key_match = True
107+
else:
108+
cert_key_match = False
109+
110+
if issuer_path:
111+
# if issuer provided, we can also check OCSP status (revocation)
112+
issuer_cert = load_cert(issuer_path)
113+
114+
builder = ocsp.OCSPRequestBuilder()
115+
builder = builder.add_certificate(cert, issuer_cert, hashes.SHA1())
116+
ocsp_request = builder.build()
117+
118+
# Extract OCSP URL from the certificate
119+
try:
120+
ocsp_urls = cert.extensions.get_extension_for_class(
121+
x509.AuthorityInformationAccess
122+
).value
123+
except x509.extensions.ExtensionNotFound:
124+
ocsp_urls = []
125+
126+
ocsp_url_candidates = [
127+
access.access_location.value
128+
for access in ocsp_urls
129+
if access.access_method == x509.AuthorityInformationAccessOID.OCSP
130+
]
131+
if ocsp_url_candidates:
132+
ocsp_url = ocsp_url_candidates[0]
133+
134+
# Encode request in DER format
135+
ocsp_request_bytes = ocsp_request.public_bytes(serialization.Encoding.DER)
136+
137+
headers = {
138+
"Content-Type": "application/ocsp-request",
139+
"Accept": "application/ocsp-response",
140+
}
141+
142+
response = requests.post(
143+
ocsp_url, data=ocsp_request_bytes, headers=headers, timeout=ocsp_timeout
144+
)
145+
146+
if response.status_code != 200:
147+
print(
148+
f"OCSP request failed with status code {response.status_code}",
149+
file=sys.stderr,
150+
)
151+
else:
152+
# === Parse OCSP Response ===
153+
ocsp_response = ocsp.load_der_ocsp_response(response.content)
154+
155+
cert_status_details["validation_status"] = str(
156+
ocsp_response.response_status
157+
)
158+
cert_status_details["cert_status"] = str(ocsp_response.certificate_status)
159+
cert_status_details["this_update"] = (
160+
ocsp_response.this_update_utc.isoformat()
161+
if ocsp_response.this_update_utc
162+
else None
163+
)
164+
cert_status_details["next_update"] = (
165+
ocsp_response.next_update_utc.isoformat()
166+
if ocsp_response.next_update_utc
167+
else None
168+
)
169+
cert_status_details["revocation_time"] = (
170+
ocsp_response.revocation_time_utc.isoformat()
171+
if ocsp_response.revocation_time_utc
172+
else None
173+
)
174+
cert_status_details["revocation_reason"] = (
175+
ocsp_response.revocation_reason.name
176+
if ocsp_response.revocation_reason is not None
177+
else None
178+
)
179+
else:
180+
print(
181+
"No OCSP URL found in certificate. Cannot check revocation",
182+
file=sys.stderr,
183+
)
184+
185+
return (
186+
{
187+
"expired": expired,
188+
"cert_key_match": cert_key_match,
189+
"serial_number": cert.serial_number,
190+
"issuer": cert.issuer.rfc4514_string(),
191+
"subject": cert.subject.rfc4514_string(),
192+
"not_valid_before": cert.not_valid_before_utc.isoformat(),
193+
"not_valid_after": cert.not_valid_after_utc.isoformat(),
194+
"cert_ocsp_details": cert_status_details,
195+
},
196+
(
197+
not expired
198+
and already_valid
199+
and cert_key_match is not False
200+
and cert_status_details.get("cert_status") != "OCSPCertStatus.REVOKED"
201+
),
202+
)
203+
204+
except Exception:
205+
traceback.print_exc()
206+
return {}, False
207+
208+
209+
def make_parser():
210+
"""Create and configures an ArgumentParser for the script.
211+
212+
Returns:
213+
argparse.ArgumentParser: The configured argument parser.
214+
215+
"""
216+
parser = argparse.ArgumentParser(description="""Certificate Checker
217+
This script checks the validity of a certificate by performing the following checks:
218+
1. Expiration Check: Verifies if the certificate is currently valid by checking:
219+
- The current time is after the 'not valid before' date
220+
- The current time is before the 'not valid after' date
221+
This check is performed every time the script is run.
222+
2. Certificate-Key Match: If a private key is provided, the script checks
223+
if it matches the public key in the certificate.
224+
3. OCSP Revocation Check: If an issuer certificate is provided, the script performs
225+
an OCSP check to determine if the certificate has been revoked.
226+
227+
The script produces the following JSON to stdout and returns 0 if all checks are
228+
successful; otherwise, it returns 1.
229+
230+
{'expired': <boolean>,
231+
'cert_key_match': <boolean>,
232+
'serial_number': <serial_number>,
233+
'issuer': <issuer>,
234+
'subject': <subject>,
235+
'not_valid_before': <YYYY-MM-DDThh:mm:ss+tz:tz>,
236+
'not_valid_after': <YYYY-MM-DDThh:mm:ss+tz:tz>,
237+
'cert_ocsp_details': {'validation_status': 'OCSPResponseStatus.<STATUS>',
238+
'cert_status': 'OCSPCertStatus.<STATUS>',
239+
'this_update': '<YYYY-MM-DDThh:mm:ss+tz:tz>',
240+
'next_update': <YYYY-MM-DDThh:mm:ss+tz:tz> or null,
241+
'revocation_time': <YYYY-MM-DDThh:mm:ss+tz:tz> or null,
242+
'revocation_reason': <reason>}}
243+
""")
244+
parser.add_argument(
245+
"--cert", required=True, help="Path to the certificate file (PEM format)"
246+
)
247+
parser.add_argument("--key", help="Path to the private key file (PEM format)")
248+
parser.add_argument("--issuer", help="Path to the issuer certificate file (PEM format)")
249+
parser.add_argument(
250+
"--ocsp-timeout",
251+
type=int,
252+
default=10,
253+
help="Timeout for OCSP request in seconds (default: 10)",
254+
)
255+
return parser
256+
257+
258+
if __name__ == "__main__":
259+
parser = make_parser()
260+
args = parser.parse_args()
261+
cert_info_result, is_valid = cert_info(
262+
args.cert,
263+
cert_key_path=args.key,
264+
issuer_path=args.issuer,
265+
ocsp_timeout=args.ocsp_timeout,
266+
)
267+
print(json.dumps(cert_info_result))
268+
if is_valid:
269+
print("Certificate check successful", file=sys.stderr)
270+
else:
271+
print("Certificate check failed", file=sys.stderr)
272+
sys.exit(1)

0 commit comments

Comments
 (0)