Skip to content

Commit bfa8d0f

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 255ce59 commit bfa8d0f

3 files changed

Lines changed: 816 additions & 0 deletions

File tree

.github/workflows/python.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
sudo apt-get install -y libkrb5-dev
2323
- name: Check out repo
2424
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
25+
with:
26+
fetch-depth: 0
2527
- name: Black Lint
2628
uses: psf/black@9fd9ea2835973981e3f5dc5b8eb76f2ded46aa61 # stable
2729
- name: Setup python environment for flake8 check
@@ -36,6 +38,27 @@ jobs:
3638
export PATH="$HOME/.local/bin:$PATH"
3739
uv sync --dev
3840
uv run pytest --cov=. --cov-report=xml:coverage.xml
41+
- name: docstring test
42+
run: |
43+
# Get list of changed python files
44+
pip install ruff
45+
FILES=$(git diff --name-only --diff-filter=ACMRT origin/${{ github.base_ref }} \
46+
${{ github.sha }} | tr '\n' ' ')
47+
if [ -z "$FILES" ]; then
48+
echo "No Python files changed."
49+
else
50+
PYFILES=""
51+
for _file in ${FILES}; do
52+
echo "Checking file : $_file"
53+
if [[ $(file --mime-type "$_file" | cut -d" " -f 2) == "text/x-script.python" ]]; then
54+
PYFILES="$PYFILES $_file "
55+
else
56+
echo "Skipping non-python file: $_file"
57+
fi
58+
done
59+
echo "Running ruff on: $PYFILES"
60+
ruff check --select D ${PYFILES}
61+
fi
3962
- name: Upload coverage to Codecov
4063
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
4164
with:

utils/check-cert.py

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

0 commit comments

Comments
 (0)