lib.tls

  1import base64
  2import hashlib
  3import json
  4import re
  5import shlex
  6import ssl
  7from dataclasses import dataclass, field
  8from ipaddress import IPv4Interface, IPv6Interface
  9from datetime import datetime
 10from urllib.parse import urlparse
 11
 12from SRE.lib_sre import Grade0, NetScheme0
 13
 14
 15def _cert_fingerprint_sha256(pem: str) -> str:
 16    """Return the SHA256 fingerprint of a PEM certificate as XX:XX:... uppercase hex."""
 17    der = ssl.PEM_cert_to_DER_cert(pem.strip())
 18    digest = hashlib.sha256(der).hexdigest()
 19    return ':'.join(digest[i:i + 2].upper() for i in range(0, len(digest), 2))
 20
 21
 22def _val(line, prefix):
 23    # OpenSSL 3 prints "sha256 Fingerprint=", OpenSSL 1.1 "SHA256 Fingerprint=": ignore the case.
 24    m = re.search(rf'{prefix}=(.+)', line, flags=re.IGNORECASE)
 25    return m.group(1).strip() if m else ''
 26
 27
 28def _cert_dict(subject_out, issuer_out, dates_out, serial_out, fingerprint_out):
 29    not_before_m = re.search(r'notBefore=(.+)', dates_out)
 30    not_after_m  = re.search(r'notAfter=(.+)',  dates_out)
 31    subject = _val(subject_out, "subject")
 32    cn_m = re.search(r'(?:^|[,/]\s*)CN\s*=\s*([^,/]+)', subject)
 33    return {
 34        "subject":      subject,
 35        "issuer":       _val(issuer_out,      "issuer"),
 36        "common_name":  cn_m.group(1).strip() if cn_m else '',
 37        "not_before":   not_before_m.group(1).strip() if not_before_m else '',
 38        "not_after":    not_after_m.group(1).strip()  if not_after_m  else '',
 39        "serial":       _val(serial_out,      "serial"),
 40        "fingerprint":  _val(fingerprint_out, "SHA256 Fingerprint"),
 41    }
 42
 43
 44def eval_rsa_private_key(grade: Grade0, machine_name: str, key_file: str,
 45                         password: str | None = None, bits: int = 4096,
 46                         cipher: str = "AES-256-CBC",
 47                         step: int = 1) -> bool:
 48    """Check that key_file is an RSA private key with the expected properties.
 49
 50    Verifies:
 51    - The file is an RSA private key (decryptable with password if provided).
 52    - The key size matches bits.
 53    - The encryption cipher in the PEM header matches cipher (case-insensitive).
 54      This applies to traditional PEM format (BEGIN RSA PRIVATE KEY); for
 55      PKCS#8 (BEGIN ENCRYPTED PRIVATE KEY) the cipher check is skipped.
 56      The cipher check is also skipped when password is None.
 57
 58    Args:
 59        grade:        the Grade0 instance.
 60        machine_name: name of the virtual machine to inspect.
 61        key_file:     absolute path to the private key file on the machine.
 62        password:     passphrase protecting the private key, or None if unencrypted.
 63        bits:         expected RSA key size (default: 4096).
 64        cipher:       expected PEM encryption cipher (default: 'AES-256-CBC').
 65        step:         step number passed to grade.test() (default: 1).
 66
 67    Returns:
 68        True if all checks pass, False otherwise.
 69    """
 70    passin = f"-passin {shlex.quote(f'pass:{password}')}" if password is not None else ""
 71    q_key_file = shlex.quote(key_file)
 72    key_text, key_code = grade.test(
 73        machine_name=machine_name,
 74        command=f"openssl rsa -in {q_key_file} {passin} -noout -text 2>&1",
 75        step=step,
 76        allow_error=True,
 77    )
 78    dek_info, _ = grade.test(
 79        machine_name=machine_name,
 80        command=f"grep 'DEK-Info' {q_key_file}",
 81        step=step,
 82        allow_error=True,
 83    )
 84
 85    if key_code != 0:
 86        return False
 87
 88    bits_m = re.search(r'Private-Key:\s*\((\d+)\s*bit', key_text)
 89    if not bits_m or int(bits_m.group(1)) != bits:
 90        return False
 91
 92    # DEK-Info line only present in traditional PEM format; skip cipher check for PKCS#8
 93    if dek_info.strip():
 94        dek_m = re.search(r'DEK-Info:\s*([^,\s]+)', dek_info)
 95        if not dek_m or dek_m.group(1).upper() != cipher.upper():
 96            return False
 97
 98    return True
 99
100
101def set_rsa_private_key(net_scheme: NetScheme0, machine_name: str, key_file: str,
102                        password: str, bits: int = 4096,
103                        cipher: str = "AES-256-CBC"):
104    """Generate an RSA private key on machine_name.
105
106    Args:
107        net_scheme:   the NetScheme0 instance.
108        machine_name: name of the virtual machine.
109        key_file:     absolute path where the key will be written on the machine.
110        password:     passphrase to protect the key.
111        bits:         RSA key size in bits (default: 4096).
112        cipher:       PEM encryption cipher (default: 'AES-256-CBC').
113    """
114    net_scheme.cmd(machine_name,
115                   f"openssl genrsa -{shlex.quote(cipher.lower())} -passout {shlex.quote(f'pass:{password}')} -out {shlex.quote(key_file)} {bits}")
116
117
118def eval_self_signed_certificate(grade: Grade0, machine_name: str,
119                                 key_file: str, cert_file: str,
120                                 password: str,
121                                 step: int = 1) -> dict | None:
122    """Check that key_file and cert_file are a password-protected TLS key and
123    a matching self-signed certificate on machine_name.
124
125    Verifies:
126    - key_file is a valid PEM private key decryptable with password.
127    - cert_file is a valid PEM certificate whose Issuer equals its Subject
128      (self-signed).
129    - The public key in the certificate matches the private key.
130
131    Args:
132        grade:        the Grade0 instance.
133        machine_name: name of the virtual machine to inspect.
134        key_file:     absolute path to the private key file on the machine.
135        cert_file:    absolute path to the certificate file on the machine.
136        password:     passphrase protecting the private key.
137        step:         step number passed to grade.test() (default: 1).
138
139    Returns:
140        A dict with certificate fields (subject, issuer, not_before, not_after,
141        serial, fingerprint) if all checks pass, None otherwise.
142    """
143    # All grade.test() calls must be made unconditionally so they are registered
144    # in the first (registration) pass and carry real results in the second pass.
145    q_key_file = shlex.quote(key_file)
146    q_cert_file = shlex.quote(cert_file)
147    q_passin = shlex.quote(f'pass:{password}')
148    _, key_code = grade.test(
149        machine_name=machine_name,
150        command=f"openssl pkey -in {q_key_file} -passin {q_passin} -noout",
151        step=step,
152        allow_error=True,
153    )
154    cert_text, cert_code = grade.test(
155        machine_name=machine_name,
156        command=f"openssl x509 -in {q_cert_file} -noout -text -fingerprint -sha256",
157        step=step,
158        allow_error=True,
159    )
160    cert_pubkey, cert_pubkey_code = grade.test(
161        machine_name=machine_name,
162        command=f"openssl x509 -in {q_cert_file} -noout -pubkey",
163        step=step,
164        allow_error=True,
165    )
166    key_pubkey, key_pubkey_code = grade.test(
167        machine_name=machine_name,
168        command=f"openssl pkey -in {q_key_file} -passin {q_passin} -pubout",
169        step=step,
170        allow_error=True,
171    )
172    subject_out, _ = grade.test(
173        machine_name=machine_name,
174        command=f"openssl x509 -in {q_cert_file} -noout -subject",
175        step=step,
176        allow_error=True,
177    )
178    issuer_out, _ = grade.test(
179        machine_name=machine_name,
180        command=f"openssl x509 -in {q_cert_file} -noout -issuer",
181        step=step,
182        allow_error=True,
183    )
184    dates_out, _ = grade.test(
185        machine_name=machine_name,
186        command=f"openssl x509 -in {q_cert_file} -noout -dates",
187        step=step,
188        allow_error=True,
189    )
190    serial_out, _ = grade.test(
191        machine_name=machine_name,
192        command=f"openssl x509 -in {q_cert_file} -noout -serial",
193        step=step,
194        allow_error=True,
195    )
196    fingerprint_out, _ = grade.test(
197        machine_name=machine_name,
198        command=f"openssl x509 -in {q_cert_file} -noout -fingerprint -sha256",
199        step=step,
200        allow_error=True,
201    )
202
203    if key_code != 0 or cert_code != 0 or cert_pubkey_code != 0 or key_pubkey_code != 0:
204        return None
205
206    if cert_pubkey.strip() != key_pubkey.strip():
207        return None
208
209    # Check self-signed: Subject must equal Issuer
210    subject_m = re.search(r'Subject:\s*(.+)', cert_text)
211    issuer_m  = re.search(r'Issuer:\s*(.+)',  cert_text)
212    if not subject_m or not issuer_m:
213        return None
214    if subject_m.group(1).strip() != issuer_m.group(1).strip():
215        return None
216
217    return _cert_dict(subject_out, issuer_out, dates_out, serial_out, fingerprint_out)
218
219
220def eval_certificate(grade: Grade0, machine_name: str,
221                     key_file: str, cert_file: str,
222                     step: int = 1) -> dict | None:
223    """Check that key_file and cert_file are a matching TLS key pair on machine_name.
224
225    Verifies:
226    - cert_file is a valid PEM certificate.
227    - The public key in the certificate matches the private key in key_file.
228
229    Args:
230        grade:        the Grade0 instance.
231        machine_name: name of the virtual machine to inspect.
232        key_file:     absolute path to the private key file on the machine.
233        cert_file:    absolute path to the certificate file on the machine.
234        step:         step number passed to grade.test() (default: 1).
235
236    Returns:
237        A dict with certificate fields (subject, issuer, common_name,
238        not_before, not_after, serial, fingerprint) if all checks pass,
239        None otherwise.
240    """
241    # All grade.test() calls must be made unconditionally so they are registered
242    # in the first (registration) pass and carry real results in the second pass.
243    q_key_file = shlex.quote(key_file)
244    q_cert_file = shlex.quote(cert_file)
245    _, cert_code = grade.test(
246        machine_name=machine_name,
247        command=f"openssl x509 -in {q_cert_file} -noout -text -fingerprint -sha256",
248        step=step,
249        allow_error=True,
250    )
251    cert_pubkey, cert_pubkey_code = grade.test(
252        machine_name=machine_name,
253        command=f"openssl x509 -in {q_cert_file} -noout -pubkey",
254        step=step,
255        allow_error=True,
256    )
257    key_pubkey, key_pubkey_code = grade.test(
258        machine_name=machine_name,
259        command=f"openssl pkey -in {q_key_file} -pubout",
260        step=step,
261        allow_error=True,
262    )
263    subject_out, _ = grade.test(
264        machine_name=machine_name,
265        command=f"openssl x509 -in {q_cert_file} -noout -subject",
266        step=step,
267        allow_error=True,
268    )
269    issuer_out, _ = grade.test(
270        machine_name=machine_name,
271        command=f"openssl x509 -in {q_cert_file} -noout -issuer",
272        step=step,
273        allow_error=True,
274    )
275    dates_out, _ = grade.test(
276        machine_name=machine_name,
277        command=f"openssl x509 -in {q_cert_file} -noout -dates",
278        step=step,
279        allow_error=True,
280    )
281    serial_out, _ = grade.test(
282        machine_name=machine_name,
283        command=f"openssl x509 -in {q_cert_file} -noout -serial",
284        step=step,
285        allow_error=True,
286    )
287    fingerprint_out, _ = grade.test(
288        machine_name=machine_name,
289        command=f"openssl x509 -in {q_cert_file} -noout -fingerprint -sha256",
290        step=step,
291        allow_error=True,
292    )
293
294    if cert_code != 0 or cert_pubkey_code != 0 or key_pubkey_code != 0:
295        return None
296
297    if cert_pubkey.strip() != key_pubkey.strip():
298        return None
299
300    return _cert_dict(subject_out, issuer_out, dates_out, serial_out, fingerprint_out)
301
302
303def eval_certificate_validity(grade: Grade0, machine_name: str,
304                              cert_file: str, ca_cert_file: str,
305                              step: int = 1, untrusted: str | None = None) -> bool:
306    """Check that cert_file is signed by ca_cert_file on machine_name.
307
308    Args:
309        grade:        the Grade0 instance.
310        machine_name: name of the virtual machine to inspect.
311        cert_file:    absolute path to the certificate file on the machine.
312        ca_cert_file: absolute path to the CA certificate file on the machine.
313        step:         step number passed to grade.test() (default: 1).
314        untrusted:    optional PEM file of intermediate certificates (``-untrusted``),
315                      e.g. the leaf bundle written by step-ca whose second block is the
316                      intermediate CA.
317
318    Returns:
319        True if cert_file is a valid certificate chaining to ca_cert_file,
320        False otherwise.
321    """
322    untrusted_opt = f" -untrusted {shlex.quote(untrusted)}" if untrusted else ""
323    _, verify_code = grade.test(
324        machine_name=machine_name,
325        command=f"openssl verify -CAfile {shlex.quote(ca_cert_file)}{untrusted_opt} {shlex.quote(cert_file)}",
326        step=step,
327        allow_error=True,
328    )
329    return verify_code == 0
330
331
332def _bracketed(ip) -> str:
333    """The address for curl / openssl: an IPv6 address goes between brackets.
334
335    *ip* may be a string or an ``ipaddress`` Address / Interface object (prefix stripped).
336    """
337    host = str(ip.ip) if isinstance(ip, (IPv4Interface, IPv6Interface)) else str(ip)
338    return f'[{host}]' if ':' in host else host
339
340
341def _host_port(ip, port: int) -> str:
342    """``host:port`` for curl / openssl, with the brackets an IPv6 address needs."""
343    return f'{_bracketed(ip)}:{port}'
344
345
346def eval_https_server(grade: Grade0, machine_name: str, url: str,
347                      server_ip, cert: str,
348                      server_port: int = 443, step: int = 1) -> bool:
349    """Check that an HTTPS server at server_ip responds correctly and presents
350    the expected certificate.
351
352    Verifies:
353    - A GET request to url (routed to server_ip:server_port) succeeds (2xx).
354    - The certificate presented by the server matches cert.
355
356    Args:
357        grade:        the Grade0 instance.
358        machine_name: name of the virtual machine from which to connect.
359        url:          full URL to request (e.g. https://myserver/index.html).
360        server_ip:    IP address of the HTTPS server to connect to (IPv4 or IPv6, as a
361                      string or an ``ipaddress`` object; IPv6 is bracketed automatically).
362        cert:         PEM certificate content to verify against the server.
363        server_port:  HTTPS port (default: 443).
364        step:         step number passed to grade.test() (default: 1).
365
366    Returns:
367        True if all checks pass, False otherwise.
368    """
369    # All grade.test() calls must be made unconditionally so they are registered
370    # in the first (registration) pass and carry real results in the second pass.
371    _, http_code = grade.test(
372        machine_name=machine_name,
373        command=f"curl -k -L --fail --connect-to ::{_host_port(server_ip, server_port)}"
374                f" -s -o /dev/null {url}",
375        step=step,
376        allow_error=True,
377    )
378    server_fp, server_fp_code = grade.test(
379        machine_name=machine_name,
380        command=f"openssl s_client -connect {_host_port(server_ip, server_port)}"
381                f" </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256",
382        step=step,
383        allow_error=True,
384    )
385
386    if http_code != 0:
387        return False
388    if server_fp_code != 0:
389        return False
390
391    try:
392        cert_fp_val = _cert_fingerprint_sha256(cert)
393    except Exception:
394        return False
395
396    server_fp_val = _val(server_fp, "SHA256 Fingerprint")
397    return bool(server_fp_val) and server_fp_val == cert_fp_val
398
399
400# ---------------------------------------------------------------------------
401# Certificate details: SAN, validity period
402# ---------------------------------------------------------------------------
403
404def parse_san(text: str) -> list[str]:
405    """Return the DNS and IP entries of an ``openssl x509 -ext subjectAltName`` output.
406
407    ``DNS:web.tp, DNS:www.web.tp, IP Address:10.0.0.1`` -> ``['web.tp', 'www.web.tp', '10.0.0.1']``.
408    """
409    return [m.group(1).strip() for m in re.finditer(r'(?:DNS|IP Address)\s*:\s*([^,\s]+)', text or '')]
410
411
412def get_certificate_san(grade: Grade0, machine_name: str, cert_file: str,
413                        step: int = 1) -> list[str]:
414    """DNS/IP names of the subjectAltName extension of cert_file (``[]`` when absent or on error)."""
415    out, code = grade.test(
416        machine_name=machine_name,
417        command=f"openssl x509 -in {shlex.quote(cert_file)} -noout -ext subjectAltName",
418        step=step,
419        allow_error=True,
420    )
421    if code != 0:
422        return []
423    return parse_san(out)
424
425
426_MONTHS = {m: i + 1 for i, m in enumerate(('jan', 'feb', 'mar', 'apr', 'may', 'jun',
427                                            'jul', 'aug', 'sep', 'oct', 'nov', 'dec'))}
428_OPENSSL_DATE_RE = re.compile(r'([A-Za-z]{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})')
429
430
431def parse_openssl_date(text: str) -> datetime | None:
432    """Parse an OpenSSL date such as ``Jan  1 00:00:00 2024 GMT`` (English month names,
433    whatever the locale of the grading process: ``strptime('%b')`` would not do)."""
434    m = _OPENSSL_DATE_RE.search(text or '')
435    if not m:
436        return None
437    month = _MONTHS.get(m.group(1).lower())
438    if month is None:
439        return None
440    try:
441        return datetime(int(m.group(6)), month, int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5)))
442    except ValueError:
443        return None
444
445
446def certificate_validity_days(cert: dict | None) -> int | None:
447    """Validity period in days of a certificate dict (``not_before``/``not_after`` keys
448    as returned by :func:`eval_certificate`), ``None`` when the dates cannot be read."""
449    if not cert:
450        return None
451    start = parse_openssl_date(cert.get('not_before', ''))
452    end = parse_openssl_date(cert.get('not_after', ''))
453    if start is None or end is None:
454        return None
455    return round((end - start).total_seconds() / 86400)
456
457
458# ---------------------------------------------------------------------------
459# CRL
460# ---------------------------------------------------------------------------
461
462def normalize_serial(serial: str) -> str:
463    """Canonical form of a certificate serial number: upper-case hex, no colons, no
464    ``0x`` prefix, no leading zeros (``'0'`` for zero)."""
465    s = re.sub(r'[\s:]', '', (serial or '')).upper()
466    if s.startswith('0X'):
467        s = s[2:]
468    s = s.lstrip('0')
469    return s or '0'
470
471
472def parse_crl_text(text: str) -> list[str]:
473    """Serial numbers (normalised) listed as revoked in an ``openssl crl -text`` output."""
474    if 'Revoked Certificates' not in (text or ''):
475        return []
476    body = text.split('Revoked Certificates', 1)[1]
477    return [normalize_serial(m.group(1)) for m in re.finditer(r'Serial Number:\s*([0-9A-Fa-f:]+)', body)]
478
479
480def get_crl_revoked_serials(grade: Grade0, machine_name: str, crl_file: str,
481                            step: int = 1) -> list[str]:
482    """Revoked serial numbers of the CRL *crl_file* (``[]`` when unreadable)."""
483    out, code = grade.test(
484        machine_name=machine_name,
485        command=f"openssl crl -in {shlex.quote(crl_file)} -noout -text",
486        step=step,
487        allow_error=True,
488    )
489    if code != 0:
490        return []
491    return parse_crl_text(out)
492
493
494def eval_crl(grade: Grade0, machine_name: str, crl_file: str, ca_cert_file: str,
495             step: int = 1) -> bool:
496    """True when *crl_file* is a CRL whose signature verifies against *ca_cert_file*."""
497    out, code = grade.test(
498        machine_name=machine_name,
499        command=f"openssl crl -in {shlex.quote(crl_file)} -CAfile {shlex.quote(ca_cert_file)} -noout 2>&1",
500        step=step,
501        allow_error=True,
502    )
503    return code == 0 and 'verify failure' not in (out or '').lower()
504
505
506# ---------------------------------------------------------------------------
507# PKCS#12
508# ---------------------------------------------------------------------------
509
510_PEM_CERT_RE = re.compile(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', re.S)
511_CN_RE = re.compile(r'(?:^|[,/]\s*)CN\s*=\s*([^,/]+)')
512
513
514def cert_sha256_hex(pem: str) -> str:
515    """Lower-case hex SHA-256 of the DER form of a PEM certificate (``''`` if unparsable)."""
516    try:
517        return hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem.strip())).hexdigest()
518    except Exception:
519        return ''
520
521
522def eval_pkcs12(grade: Grade0, machine_name: str, p12_file: str, password: str,
523                step: int = 1) -> dict | None:
524    """Open the PKCS#12 file *p12_file* with *password* on machine_name.
525
526    Returns ``None`` when the file cannot be opened (missing file or wrong password),
527    otherwise ``{'subject', 'common_name', 'fingerprint'}`` of the client certificate,
528    ``'ca_fingerprints'`` (SHA-256 fingerprints of the CA certificates bundled in the file)
529    and ``'has_key'`` (a private key is present).
530    """
531    q_file = shlex.quote(p12_file)
532    q_pass = shlex.quote(f'pass:{password}')
533    cert_out, cert_code = grade.test(
534        machine_name=machine_name,
535        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nokeys -clcerts 2>/dev/null"
536                f" | openssl x509 -noout -subject -fingerprint -sha256",
537        step=step,
538        allow_error=True,
539    )
540    ca_out, _ = grade.test(
541        machine_name=machine_name,
542        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nokeys -cacerts 2>/dev/null",
543        step=step,
544        allow_error=True,
545    )
546    _, key_code = grade.test(
547        machine_name=machine_name,
548        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nocerts -nodes 2>/dev/null"
549                f" | openssl pkey -noout",
550        step=step,
551        allow_error=True,
552    )
553    if cert_code != 0 or 'subject=' not in (cert_out or ''):
554        return None
555    subject = _val(cert_out, 'subject')
556    cn_m = _CN_RE.search(subject)
557    ca_fps = []
558    for pem in _PEM_CERT_RE.findall(ca_out or ''):
559        try:
560            ca_fps.append(_cert_fingerprint_sha256(pem))
561        except Exception:
562            continue
563    return {
564        'subject': subject,
565        'common_name': cn_m.group(1).strip() if cn_m else '',
566        'fingerprint': _val(cert_out, 'SHA256 Fingerprint'),
567        'ca_fingerprints': ca_fps,
568        'has_key': key_code == 0,
569    }
570
571
572# ---------------------------------------------------------------------------
573# Live TLS servers
574# ---------------------------------------------------------------------------
575
576def get_tls_server_certificate(grade: Grade0, machine_name: str, server_ip: str,
577                               port: int = 443, servername: str | None = None,
578                               step: int = 1, starttls: str | None = None) -> dict | None:
579    """Certificate presented by the TLS server at *server_ip*:*port* (SNI *servername*),
580    as ``{'subject', 'issuer', 'common_name', 'fingerprint'}``; ``None`` when no
581    certificate could be read.  *starttls* names the protocol of an opportunistic TLS
582    server (``'smtp'``, ``'imap'``, ``'pop3'``...: ``openssl s_client -starttls``)."""
583    sni = f" -servername {shlex.quote(servername)}" if servername else ""
584    opportunistic = f" -starttls {shlex.quote(starttls)}" if starttls else ""
585    out, code = grade.test(
586        machine_name=machine_name,
587        command=f"openssl s_client -connect {_host_port(server_ip, int(port))}{sni}{opportunistic}"
588                f" </dev/null 2>/dev/null"
589                f" | openssl x509 -noout -subject -issuer -fingerprint -sha256",
590        step=step,
591        allow_error=True,
592    )
593    if code != 0 or 'subject=' not in (out or ''):
594        return None
595    subject = _val(out, 'subject')
596    cn_m = _CN_RE.search(subject)
597    return {
598        'subject': subject,
599        'issuer': _val(out, 'issuer'),
600        'common_name': cn_m.group(1).strip() if cn_m else '',
601        'fingerprint': _val(out, 'SHA256 Fingerprint'),
602    }
603
604
605_CURL_CODE_MARK = '===SRE_CODE '
606
607
608@dataclass
609class HttpResult:
610    """Result of :func:`https_get`: *code* is the HTTP status (``None`` when the request
611    failed before an answer, e.g. a certificate error), *headers* are lower-cased (the last value of a
612    repeated header), *all_headers* keeps every ``(name, value)`` in order (several ``Set-Cookie``)."""
613    code: int | None = None
614    headers: dict = field(default_factory=dict)
615    all_headers: list = field(default_factory=list)
616    body: str = ''
617    exit_code: int = 0
618
619    @property
620    def ok(self) -> bool:
621        return self.code is not None and 200 <= self.code < 300
622
623
624def parse_curl_output(out: str, exit_code: int = 0) -> HttpResult:
625    """Parse the output of ``curl -D - -o - -w '\\n===SRE_CODE %{http_code}'``."""
626    text = out or ''
627    code = None
628    if _CURL_CODE_MARK in text:
629        text, _, tail = text.rpartition(_CURL_CODE_MARK)
630        try:
631            code = int(tail.strip())
632        except ValueError:
633            code = None
634        if code == 0:
635            code = None
636    headers: dict = {}
637    all_headers: list = []
638    body = text
639    if text.startswith('HTTP/'):
640        m = re.search(r'\r?\n\r?\n', text)
641        head, body = (text[:m.start()], text[m.end():]) if m else (text, '')
642        for line in head.splitlines()[1:]:
643            if ':' in line:
644                name, value = line.split(':', 1)
645                headers[name.strip().lower()] = value.strip()
646                all_headers.append((name.strip(), value.strip()))
647    return HttpResult(code=code, headers=headers, all_headers=all_headers, body=body, exit_code=exit_code)
648
649
650def https_get(grade: Grade0, machine_name: str, url: str, server_ip: str,
651              port: int = 443, cacert: str | None = None, cert: str | None = None,
652              key: str | None = None, insecure: bool = False,
653              step: int = 1, timeout: int = 15) -> HttpResult:
654    """GET *url* from machine_name, connecting to *server_ip*:*port* for the URL's host
655    name (``curl --resolve``: no DNS needed, SNI and Host header come from the URL).
656
657    *cacert* / *cert* / *key* are paths on the machine (``--cacert``, ``--cert``, ``--key``);
658    *insecure* adds ``-k``. The status code, headers and body are returned in an
659    :class:`HttpResult` (``code`` is ``None`` when the TLS handshake or the connection failed).
660    """
661    host = urlparse(url).hostname or ''
662    opts = [f"--resolve {shlex.quote(f'{host}:{int(port)}:{_bracketed(server_ip)}')}"]
663    if insecure:
664        opts.append("-k")
665    if cacert:
666        opts.append(f"--cacert {shlex.quote(cacert)}")
667    if cert:
668        opts.append(f"--cert {shlex.quote(cert)}")
669    if key:
670        opts.append(f"--key {shlex.quote(key)}")
671    max_time = max(1, timeout - 3)
672    cmd = (f"curl -s -S --max-time {max_time} {' '.join(opts)} -D - -o - "
673           f"-w '\\n{_CURL_CODE_MARK}%{{http_code}}' {shlex.quote(url)} 2>&1")
674    out, code = grade.test(machine_name=machine_name, command=cmd, step=step,
675                           timeout=timeout, allow_error=True)
676    return parse_curl_output(out, code)
677
678
679# ---------------------------------------------------------------------------
680# Firefox (NSS) certificate store
681# ---------------------------------------------------------------------------
682
683def nss_hashes_command(home: str = '/root') -> str:
684    """One-line shell command printing the SHA-256 (hex) of every certificate stored in the
685    Firefox profiles under *home* (``cert9.db``, SQLite table ``nssPublic``, column ``a11`` =
686    CKA_VALUE). The databases are opened read-only and immutable so a running Firefox is
687    not disturbed. One hash per line; nothing when no profile exists.
688
689    The profiles are looked for in ``~/.mozilla/firefox`` and in ``~/.config/mozilla/firefox``:
690    Firefox 153 ESR (images 1.31) creates its profile in the second one when ``~/.mozilla`` does
691    not exist (seen live 2026-11-11: the first directory alone found nothing).
692
693    The script is base64-encoded into a ``python3 -c`` one-liner: the exetests runner only
694    supports single-line commands."""
695    script = (
696        "import glob, hashlib, sqlite3\n"
697        "seen = set()\n"
698        f"for db in glob.glob({home!r} + '/.mozilla/firefox*/*/cert9.db') "
699        f"+ glob.glob({home!r} + '/.config/mozilla/firefox*/*/cert9.db'):\n"
700        "    try:\n"
701        "        con = sqlite3.connect('file:' + db + '?mode=ro&immutable=1', uri=True)\n"
702        "        for (blob,) in con.execute('SELECT a11 FROM nssPublic WHERE a11 IS NOT NULL'):\n"
703        "            if isinstance(blob, bytes) and blob[:1] == b'\\x30':\n"
704        "                seen.add(hashlib.sha256(blob).hexdigest())\n"
705        "        con.close()\n"
706        "    except Exception:\n"
707        "        pass\n"
708        "print('\\n'.join(sorted(seen)))\n"
709    )
710    b64 = base64.b64encode(script.encode()).decode()
711    return f'python3 -c "import base64; exec(base64.b64decode(\'{b64}\').decode())"'
712
713
714def get_nss_certificate_hashes(grade: Grade0, machine_name: str, home: str = '/root',
715                               step: int = 1) -> set[str]:
716    """SHA-256 hashes of the certificates in the Firefox profiles of *home* on machine_name."""
717    out, code = grade.test(machine_name=machine_name, command=nss_hashes_command(home),
718                           step=step, allow_error=True)
719    if code != 0:
720        return set()
721    return {line.strip() for line in (out or '').splitlines() if re.fullmatch(r'[0-9a-f]{64}', line.strip())}
722
723
724def eval_firefox_certificate(grade: Grade0, machine_name: str, cert_pem: str,
725                             home: str = '/root', step: int = 1) -> bool:
726    """True when the PEM certificate *cert_pem* has been imported into a Firefox profile of
727    *home* on machine_name (any trust setting)."""
728    hashes = get_nss_certificate_hashes(grade, machine_name, home=home, step=step)
729    wanted = cert_sha256_hex(cert_pem)
730    return bool(wanted) and wanted in hashes
731
732
733# ---------------------------------------------------------------------------
734# step-ca
735# ---------------------------------------------------------------------------
736
737def generate_ca_pem(common_name: str, days: int = 3650, key_size: int = 2048) -> tuple[str, str]:
738    """``(certificate_pem, private_key_pem)`` of a fresh self-signed RSA certification authority
739    (``CA:TRUE``, keyCertSign / cRLSign, subject key identifier), for labs that hand a ready CA
740    to the students (the DNS lab's ``ca.tp`` signing the DoT / DoH certificate)."""
741    from datetime import datetime, timedelta, timezone
742    from cryptography import x509
743    from cryptography.hazmat.primitives import hashes, serialization
744    from cryptography.hazmat.primitives.asymmetric import rsa
745    from cryptography.x509.oid import NameOID
746    key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
747    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
748    now = datetime.now(timezone.utc) - timedelta(days=1)
749    cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
750            .serial_number(x509.random_serial_number()).not_valid_before(now)
751            .not_valid_after(now + timedelta(days=days))
752            .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
753            .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False,
754                                         data_encipherment=False, key_agreement=False, key_cert_sign=True,
755                                         crl_sign=True, encipher_only=False, decipher_only=False), critical=True)
756            .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
757            .sign(key, hashes.SHA256()))
758    cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
759    key_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL,
760                                serialization.NoEncryption()).decode()
761    return cert_pem, key_pem
762
763
764def generate_server_cert_pem(ca_cert_pem: str, ca_key_pem: str, hostname: str, days: int = 365,
765                             key_size: int = 2048, extra_names: tuple[str, ...] = ()) -> tuple[str, str]:
766    """``(certificate_pem, private_key_pem)`` of a fresh RSA server certificate for *hostname*
767    (CN and ``subjectAltName`` dNSName, plus *extra_names*), signed by the CA of
768    :func:`generate_ca_pem`: what a reference solution installs on a TLS server (the LDAP lab's
769    ``ldap1.corp.lan``) without running ``openssl`` in the container."""
770    from datetime import datetime, timedelta, timezone
771    from cryptography import x509
772    from cryptography.hazmat.primitives import hashes, serialization
773    from cryptography.hazmat.primitives.asymmetric import rsa
774    from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
775    ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode())
776    ca_key = serialization.load_pem_private_key(ca_key_pem.encode(), password=None)
777    key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
778    now = datetime.now(timezone.utc) - timedelta(days=1)
779    names = [x509.DNSName(hostname)] + [x509.DNSName(n) for n in extra_names]
780    cert = (x509.CertificateBuilder()
781            .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
782            .issuer_name(ca_cert.subject).public_key(key.public_key())
783            .serial_number(x509.random_serial_number()).not_valid_before(now)
784            .not_valid_after(now + timedelta(days=days))
785            .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
786            .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=True,
787                                         data_encipherment=False, key_agreement=False, key_cert_sign=False,
788                                         crl_sign=False, encipher_only=False, decipher_only=False), critical=True)
789            .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
790            .add_extension(x509.SubjectAlternativeName(names), critical=False)
791            .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
792            .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
793            .sign(ca_key, hashes.SHA256()))
794    cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
795    key_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL,
796                                serialization.NoEncryption()).decode()
797    return cert_pem, key_pem
798
799
800def parse_stepca_config(text: str) -> dict | None:
801    """Summary of a step-ca ``ca.json``: ``{'address', 'dns_names', 'root', 'provisioners':
802    [{'name', 'type'}, ...]}``; ``None`` when the text is not a JSON object."""
803    try:
804        cfg = json.loads(text or '')
805    except (ValueError, TypeError):
806        return None
807    if not isinstance(cfg, dict):
808        return None
809    provisioners = (cfg.get('authority') or {}).get('provisioners') or []
810    return {
811        'address': cfg.get('address', ''),
812        'dns_names': list(cfg.get('dnsNames') or []),
813        'root': cfg.get('root', ''),
814        'provisioners': [{'name': p.get('name', ''), 'type': p.get('type', '')}
815                         for p in provisioners if isinstance(p, dict)],
816    }
817
818
819def get_stepca_config(grade: Grade0, machine_name: str,
820                      config_file: str = '/home/ca/.step/config/ca.json',
821                      step: int = 1) -> dict | None:
822    """Parsed step-ca configuration of machine_name (see :func:`parse_stepca_config`)."""
823    out, code = grade.test(machine_name=machine_name, command=f"cat {shlex.quote(config_file)}",
824                           step=step, allow_error=True)
825    if code != 0:
826        return None
827    return parse_stepca_config(out)
def eval_rsa_private_key( grade: SRE.lib_sre.Grade0, machine_name: str, key_file: str, password: str | None = None, bits: int = 4096, cipher: str = 'AES-256-CBC', step: int = 1) -> bool:
45def eval_rsa_private_key(grade: Grade0, machine_name: str, key_file: str,
46                         password: str | None = None, bits: int = 4096,
47                         cipher: str = "AES-256-CBC",
48                         step: int = 1) -> bool:
49    """Check that key_file is an RSA private key with the expected properties.
50
51    Verifies:
52    - The file is an RSA private key (decryptable with password if provided).
53    - The key size matches bits.
54    - The encryption cipher in the PEM header matches cipher (case-insensitive).
55      This applies to traditional PEM format (BEGIN RSA PRIVATE KEY); for
56      PKCS#8 (BEGIN ENCRYPTED PRIVATE KEY) the cipher check is skipped.
57      The cipher check is also skipped when password is None.
58
59    Args:
60        grade:        the Grade0 instance.
61        machine_name: name of the virtual machine to inspect.
62        key_file:     absolute path to the private key file on the machine.
63        password:     passphrase protecting the private key, or None if unencrypted.
64        bits:         expected RSA key size (default: 4096).
65        cipher:       expected PEM encryption cipher (default: 'AES-256-CBC').
66        step:         step number passed to grade.test() (default: 1).
67
68    Returns:
69        True if all checks pass, False otherwise.
70    """
71    passin = f"-passin {shlex.quote(f'pass:{password}')}" if password is not None else ""
72    q_key_file = shlex.quote(key_file)
73    key_text, key_code = grade.test(
74        machine_name=machine_name,
75        command=f"openssl rsa -in {q_key_file} {passin} -noout -text 2>&1",
76        step=step,
77        allow_error=True,
78    )
79    dek_info, _ = grade.test(
80        machine_name=machine_name,
81        command=f"grep 'DEK-Info' {q_key_file}",
82        step=step,
83        allow_error=True,
84    )
85
86    if key_code != 0:
87        return False
88
89    bits_m = re.search(r'Private-Key:\s*\((\d+)\s*bit', key_text)
90    if not bits_m or int(bits_m.group(1)) != bits:
91        return False
92
93    # DEK-Info line only present in traditional PEM format; skip cipher check for PKCS#8
94    if dek_info.strip():
95        dek_m = re.search(r'DEK-Info:\s*([^,\s]+)', dek_info)
96        if not dek_m or dek_m.group(1).upper() != cipher.upper():
97            return False
98
99    return True

Check that key_file is an RSA private key with the expected properties.

Verifies:

  • The file is an RSA private key (decryptable with password if provided).
  • The key size matches bits.
  • The encryption cipher in the PEM header matches cipher (case-insensitive). This applies to traditional PEM format (BEGIN RSA PRIVATE KEY); for PKCS#8 (BEGIN ENCRYPTED PRIVATE KEY) the cipher check is skipped. The cipher check is also skipped when password is None.
Arguments:
  • grade: the Grade0 instance.
  • machine_name: name of the virtual machine to inspect.
  • key_file: absolute path to the private key file on the machine.
  • password: passphrase protecting the private key, or None if unencrypted.
  • bits: expected RSA key size (default: 4096).
  • cipher: expected PEM encryption cipher (default: 'AES-256-CBC').
  • step: step number passed to grade.test() (default: 1).
Returns:

True if all checks pass, False otherwise.

def set_rsa_private_key( net_scheme: SRE.lib_sre.NetScheme0, machine_name: str, key_file: str, password: str, bits: int = 4096, cipher: str = 'AES-256-CBC'):
102def set_rsa_private_key(net_scheme: NetScheme0, machine_name: str, key_file: str,
103                        password: str, bits: int = 4096,
104                        cipher: str = "AES-256-CBC"):
105    """Generate an RSA private key on machine_name.
106
107    Args:
108        net_scheme:   the NetScheme0 instance.
109        machine_name: name of the virtual machine.
110        key_file:     absolute path where the key will be written on the machine.
111        password:     passphrase to protect the key.
112        bits:         RSA key size in bits (default: 4096).
113        cipher:       PEM encryption cipher (default: 'AES-256-CBC').
114    """
115    net_scheme.cmd(machine_name,
116                   f"openssl genrsa -{shlex.quote(cipher.lower())} -passout {shlex.quote(f'pass:{password}')} -out {shlex.quote(key_file)} {bits}")

Generate an RSA private key on machine_name.

Arguments:
  • net_scheme: the NetScheme0 instance.
  • machine_name: name of the virtual machine.
  • key_file: absolute path where the key will be written on the machine.
  • password: passphrase to protect the key.
  • bits: RSA key size in bits (default: 4096).
  • cipher: PEM encryption cipher (default: 'AES-256-CBC').
def eval_self_signed_certificate( grade: SRE.lib_sre.Grade0, machine_name: str, key_file: str, cert_file: str, password: str, step: int = 1) -> dict | None:
119def eval_self_signed_certificate(grade: Grade0, machine_name: str,
120                                 key_file: str, cert_file: str,
121                                 password: str,
122                                 step: int = 1) -> dict | None:
123    """Check that key_file and cert_file are a password-protected TLS key and
124    a matching self-signed certificate on machine_name.
125
126    Verifies:
127    - key_file is a valid PEM private key decryptable with password.
128    - cert_file is a valid PEM certificate whose Issuer equals its Subject
129      (self-signed).
130    - The public key in the certificate matches the private key.
131
132    Args:
133        grade:        the Grade0 instance.
134        machine_name: name of the virtual machine to inspect.
135        key_file:     absolute path to the private key file on the machine.
136        cert_file:    absolute path to the certificate file on the machine.
137        password:     passphrase protecting the private key.
138        step:         step number passed to grade.test() (default: 1).
139
140    Returns:
141        A dict with certificate fields (subject, issuer, not_before, not_after,
142        serial, fingerprint) if all checks pass, None otherwise.
143    """
144    # All grade.test() calls must be made unconditionally so they are registered
145    # in the first (registration) pass and carry real results in the second pass.
146    q_key_file = shlex.quote(key_file)
147    q_cert_file = shlex.quote(cert_file)
148    q_passin = shlex.quote(f'pass:{password}')
149    _, key_code = grade.test(
150        machine_name=machine_name,
151        command=f"openssl pkey -in {q_key_file} -passin {q_passin} -noout",
152        step=step,
153        allow_error=True,
154    )
155    cert_text, cert_code = grade.test(
156        machine_name=machine_name,
157        command=f"openssl x509 -in {q_cert_file} -noout -text -fingerprint -sha256",
158        step=step,
159        allow_error=True,
160    )
161    cert_pubkey, cert_pubkey_code = grade.test(
162        machine_name=machine_name,
163        command=f"openssl x509 -in {q_cert_file} -noout -pubkey",
164        step=step,
165        allow_error=True,
166    )
167    key_pubkey, key_pubkey_code = grade.test(
168        machine_name=machine_name,
169        command=f"openssl pkey -in {q_key_file} -passin {q_passin} -pubout",
170        step=step,
171        allow_error=True,
172    )
173    subject_out, _ = grade.test(
174        machine_name=machine_name,
175        command=f"openssl x509 -in {q_cert_file} -noout -subject",
176        step=step,
177        allow_error=True,
178    )
179    issuer_out, _ = grade.test(
180        machine_name=machine_name,
181        command=f"openssl x509 -in {q_cert_file} -noout -issuer",
182        step=step,
183        allow_error=True,
184    )
185    dates_out, _ = grade.test(
186        machine_name=machine_name,
187        command=f"openssl x509 -in {q_cert_file} -noout -dates",
188        step=step,
189        allow_error=True,
190    )
191    serial_out, _ = grade.test(
192        machine_name=machine_name,
193        command=f"openssl x509 -in {q_cert_file} -noout -serial",
194        step=step,
195        allow_error=True,
196    )
197    fingerprint_out, _ = grade.test(
198        machine_name=machine_name,
199        command=f"openssl x509 -in {q_cert_file} -noout -fingerprint -sha256",
200        step=step,
201        allow_error=True,
202    )
203
204    if key_code != 0 or cert_code != 0 or cert_pubkey_code != 0 or key_pubkey_code != 0:
205        return None
206
207    if cert_pubkey.strip() != key_pubkey.strip():
208        return None
209
210    # Check self-signed: Subject must equal Issuer
211    subject_m = re.search(r'Subject:\s*(.+)', cert_text)
212    issuer_m  = re.search(r'Issuer:\s*(.+)',  cert_text)
213    if not subject_m or not issuer_m:
214        return None
215    if subject_m.group(1).strip() != issuer_m.group(1).strip():
216        return None
217
218    return _cert_dict(subject_out, issuer_out, dates_out, serial_out, fingerprint_out)

Check that key_file and cert_file are a password-protected TLS key and a matching self-signed certificate on machine_name.

Verifies:

  • key_file is a valid PEM private key decryptable with password.
  • cert_file is a valid PEM certificate whose Issuer equals its Subject (self-signed).
  • The public key in the certificate matches the private key.
Arguments:
  • grade: the Grade0 instance.
  • machine_name: name of the virtual machine to inspect.
  • key_file: absolute path to the private key file on the machine.
  • cert_file: absolute path to the certificate file on the machine.
  • password: passphrase protecting the private key.
  • step: step number passed to grade.test() (default: 1).
Returns:

A dict with certificate fields (subject, issuer, not_before, not_after, serial, fingerprint) if all checks pass, None otherwise.

def eval_certificate( grade: SRE.lib_sre.Grade0, machine_name: str, key_file: str, cert_file: str, step: int = 1) -> dict | None:
221def eval_certificate(grade: Grade0, machine_name: str,
222                     key_file: str, cert_file: str,
223                     step: int = 1) -> dict | None:
224    """Check that key_file and cert_file are a matching TLS key pair on machine_name.
225
226    Verifies:
227    - cert_file is a valid PEM certificate.
228    - The public key in the certificate matches the private key in key_file.
229
230    Args:
231        grade:        the Grade0 instance.
232        machine_name: name of the virtual machine to inspect.
233        key_file:     absolute path to the private key file on the machine.
234        cert_file:    absolute path to the certificate file on the machine.
235        step:         step number passed to grade.test() (default: 1).
236
237    Returns:
238        A dict with certificate fields (subject, issuer, common_name,
239        not_before, not_after, serial, fingerprint) if all checks pass,
240        None otherwise.
241    """
242    # All grade.test() calls must be made unconditionally so they are registered
243    # in the first (registration) pass and carry real results in the second pass.
244    q_key_file = shlex.quote(key_file)
245    q_cert_file = shlex.quote(cert_file)
246    _, cert_code = grade.test(
247        machine_name=machine_name,
248        command=f"openssl x509 -in {q_cert_file} -noout -text -fingerprint -sha256",
249        step=step,
250        allow_error=True,
251    )
252    cert_pubkey, cert_pubkey_code = grade.test(
253        machine_name=machine_name,
254        command=f"openssl x509 -in {q_cert_file} -noout -pubkey",
255        step=step,
256        allow_error=True,
257    )
258    key_pubkey, key_pubkey_code = grade.test(
259        machine_name=machine_name,
260        command=f"openssl pkey -in {q_key_file} -pubout",
261        step=step,
262        allow_error=True,
263    )
264    subject_out, _ = grade.test(
265        machine_name=machine_name,
266        command=f"openssl x509 -in {q_cert_file} -noout -subject",
267        step=step,
268        allow_error=True,
269    )
270    issuer_out, _ = grade.test(
271        machine_name=machine_name,
272        command=f"openssl x509 -in {q_cert_file} -noout -issuer",
273        step=step,
274        allow_error=True,
275    )
276    dates_out, _ = grade.test(
277        machine_name=machine_name,
278        command=f"openssl x509 -in {q_cert_file} -noout -dates",
279        step=step,
280        allow_error=True,
281    )
282    serial_out, _ = grade.test(
283        machine_name=machine_name,
284        command=f"openssl x509 -in {q_cert_file} -noout -serial",
285        step=step,
286        allow_error=True,
287    )
288    fingerprint_out, _ = grade.test(
289        machine_name=machine_name,
290        command=f"openssl x509 -in {q_cert_file} -noout -fingerprint -sha256",
291        step=step,
292        allow_error=True,
293    )
294
295    if cert_code != 0 or cert_pubkey_code != 0 or key_pubkey_code != 0:
296        return None
297
298    if cert_pubkey.strip() != key_pubkey.strip():
299        return None
300
301    return _cert_dict(subject_out, issuer_out, dates_out, serial_out, fingerprint_out)

Check that key_file and cert_file are a matching TLS key pair on machine_name.

Verifies:

  • cert_file is a valid PEM certificate.
  • The public key in the certificate matches the private key in key_file.
Arguments:
  • grade: the Grade0 instance.
  • machine_name: name of the virtual machine to inspect.
  • key_file: absolute path to the private key file on the machine.
  • cert_file: absolute path to the certificate file on the machine.
  • step: step number passed to grade.test() (default: 1).
Returns:

A dict with certificate fields (subject, issuer, common_name, not_before, not_after, serial, fingerprint) if all checks pass, None otherwise.

def eval_certificate_validity( grade: SRE.lib_sre.Grade0, machine_name: str, cert_file: str, ca_cert_file: str, step: int = 1, untrusted: str | None = None) -> bool:
304def eval_certificate_validity(grade: Grade0, machine_name: str,
305                              cert_file: str, ca_cert_file: str,
306                              step: int = 1, untrusted: str | None = None) -> bool:
307    """Check that cert_file is signed by ca_cert_file on machine_name.
308
309    Args:
310        grade:        the Grade0 instance.
311        machine_name: name of the virtual machine to inspect.
312        cert_file:    absolute path to the certificate file on the machine.
313        ca_cert_file: absolute path to the CA certificate file on the machine.
314        step:         step number passed to grade.test() (default: 1).
315        untrusted:    optional PEM file of intermediate certificates (``-untrusted``),
316                      e.g. the leaf bundle written by step-ca whose second block is the
317                      intermediate CA.
318
319    Returns:
320        True if cert_file is a valid certificate chaining to ca_cert_file,
321        False otherwise.
322    """
323    untrusted_opt = f" -untrusted {shlex.quote(untrusted)}" if untrusted else ""
324    _, verify_code = grade.test(
325        machine_name=machine_name,
326        command=f"openssl verify -CAfile {shlex.quote(ca_cert_file)}{untrusted_opt} {shlex.quote(cert_file)}",
327        step=step,
328        allow_error=True,
329    )
330    return verify_code == 0

Check that cert_file is signed by ca_cert_file on machine_name.

Arguments:
  • grade: the Grade0 instance.
  • machine_name: name of the virtual machine to inspect.
  • cert_file: absolute path to the certificate file on the machine.
  • ca_cert_file: absolute path to the CA certificate file on the machine.
  • step: step number passed to grade.test() (default: 1).
  • untrusted: optional PEM file of intermediate certificates (-untrusted), e.g. the leaf bundle written by step-ca whose second block is the intermediate CA.
Returns:

True if cert_file is a valid certificate chaining to ca_cert_file, False otherwise.

def eval_https_server( grade: SRE.lib_sre.Grade0, machine_name: str, url: str, server_ip, cert: str, server_port: int = 443, step: int = 1) -> bool:
347def eval_https_server(grade: Grade0, machine_name: str, url: str,
348                      server_ip, cert: str,
349                      server_port: int = 443, step: int = 1) -> bool:
350    """Check that an HTTPS server at server_ip responds correctly and presents
351    the expected certificate.
352
353    Verifies:
354    - A GET request to url (routed to server_ip:server_port) succeeds (2xx).
355    - The certificate presented by the server matches cert.
356
357    Args:
358        grade:        the Grade0 instance.
359        machine_name: name of the virtual machine from which to connect.
360        url:          full URL to request (e.g. https://myserver/index.html).
361        server_ip:    IP address of the HTTPS server to connect to (IPv4 or IPv6, as a
362                      string or an ``ipaddress`` object; IPv6 is bracketed automatically).
363        cert:         PEM certificate content to verify against the server.
364        server_port:  HTTPS port (default: 443).
365        step:         step number passed to grade.test() (default: 1).
366
367    Returns:
368        True if all checks pass, False otherwise.
369    """
370    # All grade.test() calls must be made unconditionally so they are registered
371    # in the first (registration) pass and carry real results in the second pass.
372    _, http_code = grade.test(
373        machine_name=machine_name,
374        command=f"curl -k -L --fail --connect-to ::{_host_port(server_ip, server_port)}"
375                f" -s -o /dev/null {url}",
376        step=step,
377        allow_error=True,
378    )
379    server_fp, server_fp_code = grade.test(
380        machine_name=machine_name,
381        command=f"openssl s_client -connect {_host_port(server_ip, server_port)}"
382                f" </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256",
383        step=step,
384        allow_error=True,
385    )
386
387    if http_code != 0:
388        return False
389    if server_fp_code != 0:
390        return False
391
392    try:
393        cert_fp_val = _cert_fingerprint_sha256(cert)
394    except Exception:
395        return False
396
397    server_fp_val = _val(server_fp, "SHA256 Fingerprint")
398    return bool(server_fp_val) and server_fp_val == cert_fp_val

Check that an HTTPS server at server_ip responds correctly and presents the expected certificate.

Verifies:

  • A GET request to url (routed to server_ip:server_port) succeeds (2xx).
  • The certificate presented by the server matches cert.
Arguments:
  • grade: the Grade0 instance.
  • machine_name: name of the virtual machine from which to connect.
  • url: full URL to request (e.g. https://myserver/index.html).
  • server_ip: IP address of the HTTPS server to connect to (IPv4 or IPv6, as a string or an ipaddress object; IPv6 is bracketed automatically).
  • cert: PEM certificate content to verify against the server.
  • server_port: HTTPS port (default: 443).
  • step: step number passed to grade.test() (default: 1).
Returns:

True if all checks pass, False otherwise.

def parse_san(text: str) -> list[str]:
405def parse_san(text: str) -> list[str]:
406    """Return the DNS and IP entries of an ``openssl x509 -ext subjectAltName`` output.
407
408    ``DNS:web.tp, DNS:www.web.tp, IP Address:10.0.0.1`` -> ``['web.tp', 'www.web.tp', '10.0.0.1']``.
409    """
410    return [m.group(1).strip() for m in re.finditer(r'(?:DNS|IP Address)\s*:\s*([^,\s]+)', text or '')]

Return the DNS and IP entries of an openssl x509 -ext subjectAltName output.

DNS:web.tp, DNS:www.web.tp, IP Address:10.0.0.1 -> ['web.tp', 'www.web.tp', '10.0.0.1'].

def get_certificate_san( grade: SRE.lib_sre.Grade0, machine_name: str, cert_file: str, step: int = 1) -> list[str]:
413def get_certificate_san(grade: Grade0, machine_name: str, cert_file: str,
414                        step: int = 1) -> list[str]:
415    """DNS/IP names of the subjectAltName extension of cert_file (``[]`` when absent or on error)."""
416    out, code = grade.test(
417        machine_name=machine_name,
418        command=f"openssl x509 -in {shlex.quote(cert_file)} -noout -ext subjectAltName",
419        step=step,
420        allow_error=True,
421    )
422    if code != 0:
423        return []
424    return parse_san(out)

DNS/IP names of the subjectAltName extension of cert_file ([] when absent or on error).

def parse_openssl_date(text: str) -> datetime.datetime | None:
432def parse_openssl_date(text: str) -> datetime | None:
433    """Parse an OpenSSL date such as ``Jan  1 00:00:00 2024 GMT`` (English month names,
434    whatever the locale of the grading process: ``strptime('%b')`` would not do)."""
435    m = _OPENSSL_DATE_RE.search(text or '')
436    if not m:
437        return None
438    month = _MONTHS.get(m.group(1).lower())
439    if month is None:
440        return None
441    try:
442        return datetime(int(m.group(6)), month, int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5)))
443    except ValueError:
444        return None

Parse an OpenSSL date such as Jan 1 00:00:00 2024 GMT (English month names, whatever the locale of the grading process: strptime('%b') would not do).

def certificate_validity_days(cert: dict | None) -> int | None:
447def certificate_validity_days(cert: dict | None) -> int | None:
448    """Validity period in days of a certificate dict (``not_before``/``not_after`` keys
449    as returned by :func:`eval_certificate`), ``None`` when the dates cannot be read."""
450    if not cert:
451        return None
452    start = parse_openssl_date(cert.get('not_before', ''))
453    end = parse_openssl_date(cert.get('not_after', ''))
454    if start is None or end is None:
455        return None
456    return round((end - start).total_seconds() / 86400)

Validity period in days of a certificate dict (not_before/not_after keys as returned by eval_certificate()), None when the dates cannot be read.

def normalize_serial(serial: str) -> str:
463def normalize_serial(serial: str) -> str:
464    """Canonical form of a certificate serial number: upper-case hex, no colons, no
465    ``0x`` prefix, no leading zeros (``'0'`` for zero)."""
466    s = re.sub(r'[\s:]', '', (serial or '')).upper()
467    if s.startswith('0X'):
468        s = s[2:]
469    s = s.lstrip('0')
470    return s or '0'

Canonical form of a certificate serial number: upper-case hex, no colons, no 0x prefix, no leading zeros ('0' for zero).

def parse_crl_text(text: str) -> list[str]:
473def parse_crl_text(text: str) -> list[str]:
474    """Serial numbers (normalised) listed as revoked in an ``openssl crl -text`` output."""
475    if 'Revoked Certificates' not in (text or ''):
476        return []
477    body = text.split('Revoked Certificates', 1)[1]
478    return [normalize_serial(m.group(1)) for m in re.finditer(r'Serial Number:\s*([0-9A-Fa-f:]+)', body)]

Serial numbers (normalised) listed as revoked in an openssl crl -text output.

def get_crl_revoked_serials( grade: SRE.lib_sre.Grade0, machine_name: str, crl_file: str, step: int = 1) -> list[str]:
481def get_crl_revoked_serials(grade: Grade0, machine_name: str, crl_file: str,
482                            step: int = 1) -> list[str]:
483    """Revoked serial numbers of the CRL *crl_file* (``[]`` when unreadable)."""
484    out, code = grade.test(
485        machine_name=machine_name,
486        command=f"openssl crl -in {shlex.quote(crl_file)} -noout -text",
487        step=step,
488        allow_error=True,
489    )
490    if code != 0:
491        return []
492    return parse_crl_text(out)

Revoked serial numbers of the CRL crl_file ([] when unreadable).

def eval_crl( grade: SRE.lib_sre.Grade0, machine_name: str, crl_file: str, ca_cert_file: str, step: int = 1) -> bool:
495def eval_crl(grade: Grade0, machine_name: str, crl_file: str, ca_cert_file: str,
496             step: int = 1) -> bool:
497    """True when *crl_file* is a CRL whose signature verifies against *ca_cert_file*."""
498    out, code = grade.test(
499        machine_name=machine_name,
500        command=f"openssl crl -in {shlex.quote(crl_file)} -CAfile {shlex.quote(ca_cert_file)} -noout 2>&1",
501        step=step,
502        allow_error=True,
503    )
504    return code == 0 and 'verify failure' not in (out or '').lower()

True when crl_file is a CRL whose signature verifies against ca_cert_file.

def cert_sha256_hex(pem: str) -> str:
515def cert_sha256_hex(pem: str) -> str:
516    """Lower-case hex SHA-256 of the DER form of a PEM certificate (``''`` if unparsable)."""
517    try:
518        return hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem.strip())).hexdigest()
519    except Exception:
520        return ''

Lower-case hex SHA-256 of the DER form of a PEM certificate ('' if unparsable).

def eval_pkcs12( grade: SRE.lib_sre.Grade0, machine_name: str, p12_file: str, password: str, step: int = 1) -> dict | None:
523def eval_pkcs12(grade: Grade0, machine_name: str, p12_file: str, password: str,
524                step: int = 1) -> dict | None:
525    """Open the PKCS#12 file *p12_file* with *password* on machine_name.
526
527    Returns ``None`` when the file cannot be opened (missing file or wrong password),
528    otherwise ``{'subject', 'common_name', 'fingerprint'}`` of the client certificate,
529    ``'ca_fingerprints'`` (SHA-256 fingerprints of the CA certificates bundled in the file)
530    and ``'has_key'`` (a private key is present).
531    """
532    q_file = shlex.quote(p12_file)
533    q_pass = shlex.quote(f'pass:{password}')
534    cert_out, cert_code = grade.test(
535        machine_name=machine_name,
536        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nokeys -clcerts 2>/dev/null"
537                f" | openssl x509 -noout -subject -fingerprint -sha256",
538        step=step,
539        allow_error=True,
540    )
541    ca_out, _ = grade.test(
542        machine_name=machine_name,
543        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nokeys -cacerts 2>/dev/null",
544        step=step,
545        allow_error=True,
546    )
547    _, key_code = grade.test(
548        machine_name=machine_name,
549        command=f"openssl pkcs12 -in {q_file} -passin {q_pass} -nocerts -nodes 2>/dev/null"
550                f" | openssl pkey -noout",
551        step=step,
552        allow_error=True,
553    )
554    if cert_code != 0 or 'subject=' not in (cert_out or ''):
555        return None
556    subject = _val(cert_out, 'subject')
557    cn_m = _CN_RE.search(subject)
558    ca_fps = []
559    for pem in _PEM_CERT_RE.findall(ca_out or ''):
560        try:
561            ca_fps.append(_cert_fingerprint_sha256(pem))
562        except Exception:
563            continue
564    return {
565        'subject': subject,
566        'common_name': cn_m.group(1).strip() if cn_m else '',
567        'fingerprint': _val(cert_out, 'SHA256 Fingerprint'),
568        'ca_fingerprints': ca_fps,
569        'has_key': key_code == 0,
570    }

Open the PKCS#12 file p12_file with password on machine_name.

Returns None when the file cannot be opened (missing file or wrong password), otherwise {'subject', 'common_name', 'fingerprint'} of the client certificate, 'ca_fingerprints' (SHA-256 fingerprints of the CA certificates bundled in the file) and 'has_key' (a private key is present).

def get_tls_server_certificate( grade: SRE.lib_sre.Grade0, machine_name: str, server_ip: str, port: int = 443, servername: str | None = None, step: int = 1, starttls: str | None = None) -> dict | None:
577def get_tls_server_certificate(grade: Grade0, machine_name: str, server_ip: str,
578                               port: int = 443, servername: str | None = None,
579                               step: int = 1, starttls: str | None = None) -> dict | None:
580    """Certificate presented by the TLS server at *server_ip*:*port* (SNI *servername*),
581    as ``{'subject', 'issuer', 'common_name', 'fingerprint'}``; ``None`` when no
582    certificate could be read.  *starttls* names the protocol of an opportunistic TLS
583    server (``'smtp'``, ``'imap'``, ``'pop3'``...: ``openssl s_client -starttls``)."""
584    sni = f" -servername {shlex.quote(servername)}" if servername else ""
585    opportunistic = f" -starttls {shlex.quote(starttls)}" if starttls else ""
586    out, code = grade.test(
587        machine_name=machine_name,
588        command=f"openssl s_client -connect {_host_port(server_ip, int(port))}{sni}{opportunistic}"
589                f" </dev/null 2>/dev/null"
590                f" | openssl x509 -noout -subject -issuer -fingerprint -sha256",
591        step=step,
592        allow_error=True,
593    )
594    if code != 0 or 'subject=' not in (out or ''):
595        return None
596    subject = _val(out, 'subject')
597    cn_m = _CN_RE.search(subject)
598    return {
599        'subject': subject,
600        'issuer': _val(out, 'issuer'),
601        'common_name': cn_m.group(1).strip() if cn_m else '',
602        'fingerprint': _val(out, 'SHA256 Fingerprint'),
603    }

Certificate presented by the TLS server at server_ip:port (SNI servername), as {'subject', 'issuer', 'common_name', 'fingerprint'}; None when no certificate could be read. starttls names the protocol of an opportunistic TLS server ('smtp', 'imap', 'pop3'...: openssl s_client -starttls).

@dataclass
class HttpResult:
609@dataclass
610class HttpResult:
611    """Result of :func:`https_get`: *code* is the HTTP status (``None`` when the request
612    failed before an answer, e.g. a certificate error), *headers* are lower-cased (the last value of a
613    repeated header), *all_headers* keeps every ``(name, value)`` in order (several ``Set-Cookie``)."""
614    code: int | None = None
615    headers: dict = field(default_factory=dict)
616    all_headers: list = field(default_factory=list)
617    body: str = ''
618    exit_code: int = 0
619
620    @property
621    def ok(self) -> bool:
622        return self.code is not None and 200 <= self.code < 300

Result of https_get(): code is the HTTP status (None when the request failed before an answer, e.g. a certificate error), headers are lower-cased (the last value of a repeated header), all_headers keeps every (name, value) in order (several Set-Cookie).

HttpResult( code: int | None = None, headers: dict = <factory>, all_headers: list = <factory>, body: str = '', exit_code: int = 0)
code: int | None = None
headers: dict
all_headers: list
body: str = ''
exit_code: int = 0
ok: bool
620    @property
621    def ok(self) -> bool:
622        return self.code is not None and 200 <= self.code < 300
def parse_curl_output(out: str, exit_code: int = 0) -> HttpResult:
625def parse_curl_output(out: str, exit_code: int = 0) -> HttpResult:
626    """Parse the output of ``curl -D - -o - -w '\\n===SRE_CODE %{http_code}'``."""
627    text = out or ''
628    code = None
629    if _CURL_CODE_MARK in text:
630        text, _, tail = text.rpartition(_CURL_CODE_MARK)
631        try:
632            code = int(tail.strip())
633        except ValueError:
634            code = None
635        if code == 0:
636            code = None
637    headers: dict = {}
638    all_headers: list = []
639    body = text
640    if text.startswith('HTTP/'):
641        m = re.search(r'\r?\n\r?\n', text)
642        head, body = (text[:m.start()], text[m.end():]) if m else (text, '')
643        for line in head.splitlines()[1:]:
644            if ':' in line:
645                name, value = line.split(':', 1)
646                headers[name.strip().lower()] = value.strip()
647                all_headers.append((name.strip(), value.strip()))
648    return HttpResult(code=code, headers=headers, all_headers=all_headers, body=body, exit_code=exit_code)

Parse the output of curl -D - -o - -w '\n===SRE_CODE %{http_code}'.

def https_get( grade: SRE.lib_sre.Grade0, machine_name: str, url: str, server_ip: str, port: int = 443, cacert: str | None = None, cert: str | None = None, key: str | None = None, insecure: bool = False, step: int = 1, timeout: int = 15) -> HttpResult:
651def https_get(grade: Grade0, machine_name: str, url: str, server_ip: str,
652              port: int = 443, cacert: str | None = None, cert: str | None = None,
653              key: str | None = None, insecure: bool = False,
654              step: int = 1, timeout: int = 15) -> HttpResult:
655    """GET *url* from machine_name, connecting to *server_ip*:*port* for the URL's host
656    name (``curl --resolve``: no DNS needed, SNI and Host header come from the URL).
657
658    *cacert* / *cert* / *key* are paths on the machine (``--cacert``, ``--cert``, ``--key``);
659    *insecure* adds ``-k``. The status code, headers and body are returned in an
660    :class:`HttpResult` (``code`` is ``None`` when the TLS handshake or the connection failed).
661    """
662    host = urlparse(url).hostname or ''
663    opts = [f"--resolve {shlex.quote(f'{host}:{int(port)}:{_bracketed(server_ip)}')}"]
664    if insecure:
665        opts.append("-k")
666    if cacert:
667        opts.append(f"--cacert {shlex.quote(cacert)}")
668    if cert:
669        opts.append(f"--cert {shlex.quote(cert)}")
670    if key:
671        opts.append(f"--key {shlex.quote(key)}")
672    max_time = max(1, timeout - 3)
673    cmd = (f"curl -s -S --max-time {max_time} {' '.join(opts)} -D - -o - "
674           f"-w '\\n{_CURL_CODE_MARK}%{{http_code}}' {shlex.quote(url)} 2>&1")
675    out, code = grade.test(machine_name=machine_name, command=cmd, step=step,
676                           timeout=timeout, allow_error=True)
677    return parse_curl_output(out, code)

GET url from machine_name, connecting to server_ip:port for the URL's host name (curl --resolve: no DNS needed, SNI and Host header come from the URL).

cacert / cert / key are paths on the machine (--cacert, --cert, --key); insecure adds -k. The status code, headers and body are returned in an HttpResult (code is None when the TLS handshake or the connection failed).

def nss_hashes_command(home: str = '/root') -> str:
684def nss_hashes_command(home: str = '/root') -> str:
685    """One-line shell command printing the SHA-256 (hex) of every certificate stored in the
686    Firefox profiles under *home* (``cert9.db``, SQLite table ``nssPublic``, column ``a11`` =
687    CKA_VALUE). The databases are opened read-only and immutable so a running Firefox is
688    not disturbed. One hash per line; nothing when no profile exists.
689
690    The profiles are looked for in ``~/.mozilla/firefox`` and in ``~/.config/mozilla/firefox``:
691    Firefox 153 ESR (images 1.31) creates its profile in the second one when ``~/.mozilla`` does
692    not exist (seen live 2026-11-11: the first directory alone found nothing).
693
694    The script is base64-encoded into a ``python3 -c`` one-liner: the exetests runner only
695    supports single-line commands."""
696    script = (
697        "import glob, hashlib, sqlite3\n"
698        "seen = set()\n"
699        f"for db in glob.glob({home!r} + '/.mozilla/firefox*/*/cert9.db') "
700        f"+ glob.glob({home!r} + '/.config/mozilla/firefox*/*/cert9.db'):\n"
701        "    try:\n"
702        "        con = sqlite3.connect('file:' + db + '?mode=ro&immutable=1', uri=True)\n"
703        "        for (blob,) in con.execute('SELECT a11 FROM nssPublic WHERE a11 IS NOT NULL'):\n"
704        "            if isinstance(blob, bytes) and blob[:1] == b'\\x30':\n"
705        "                seen.add(hashlib.sha256(blob).hexdigest())\n"
706        "        con.close()\n"
707        "    except Exception:\n"
708        "        pass\n"
709        "print('\\n'.join(sorted(seen)))\n"
710    )
711    b64 = base64.b64encode(script.encode()).decode()
712    return f'python3 -c "import base64; exec(base64.b64decode(\'{b64}\').decode())"'

One-line shell command printing the SHA-256 (hex) of every certificate stored in the Firefox profiles under home (cert9.db, SQLite table nssPublic, column a11 = CKA_VALUE). The databases are opened read-only and immutable so a running Firefox is not disturbed. One hash per line; nothing when no profile exists.

The profiles are looked for in ~/.mozilla/firefox and in ~/.config/mozilla/firefox: Firefox 153 ESR (images 1.31) creates its profile in the second one when ~/.mozilla does not exist (seen live 2026-11-11: the first directory alone found nothing).

The script is base64-encoded into a python3 -c one-liner: the exetests runner only supports single-line commands.

def get_nss_certificate_hashes( grade: SRE.lib_sre.Grade0, machine_name: str, home: str = '/root', step: int = 1) -> set[str]:
715def get_nss_certificate_hashes(grade: Grade0, machine_name: str, home: str = '/root',
716                               step: int = 1) -> set[str]:
717    """SHA-256 hashes of the certificates in the Firefox profiles of *home* on machine_name."""
718    out, code = grade.test(machine_name=machine_name, command=nss_hashes_command(home),
719                           step=step, allow_error=True)
720    if code != 0:
721        return set()
722    return {line.strip() for line in (out or '').splitlines() if re.fullmatch(r'[0-9a-f]{64}', line.strip())}

SHA-256 hashes of the certificates in the Firefox profiles of home on machine_name.

def eval_firefox_certificate( grade: SRE.lib_sre.Grade0, machine_name: str, cert_pem: str, home: str = '/root', step: int = 1) -> bool:
725def eval_firefox_certificate(grade: Grade0, machine_name: str, cert_pem: str,
726                             home: str = '/root', step: int = 1) -> bool:
727    """True when the PEM certificate *cert_pem* has been imported into a Firefox profile of
728    *home* on machine_name (any trust setting)."""
729    hashes = get_nss_certificate_hashes(grade, machine_name, home=home, step=step)
730    wanted = cert_sha256_hex(cert_pem)
731    return bool(wanted) and wanted in hashes

True when the PEM certificate cert_pem has been imported into a Firefox profile of home on machine_name (any trust setting).

def generate_ca_pem( common_name: str, days: int = 3650, key_size: int = 2048) -> tuple[str, str]:
738def generate_ca_pem(common_name: str, days: int = 3650, key_size: int = 2048) -> tuple[str, str]:
739    """``(certificate_pem, private_key_pem)`` of a fresh self-signed RSA certification authority
740    (``CA:TRUE``, keyCertSign / cRLSign, subject key identifier), for labs that hand a ready CA
741    to the students (the DNS lab's ``ca.tp`` signing the DoT / DoH certificate)."""
742    from datetime import datetime, timedelta, timezone
743    from cryptography import x509
744    from cryptography.hazmat.primitives import hashes, serialization
745    from cryptography.hazmat.primitives.asymmetric import rsa
746    from cryptography.x509.oid import NameOID
747    key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
748    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
749    now = datetime.now(timezone.utc) - timedelta(days=1)
750    cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
751            .serial_number(x509.random_serial_number()).not_valid_before(now)
752            .not_valid_after(now + timedelta(days=days))
753            .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
754            .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False,
755                                         data_encipherment=False, key_agreement=False, key_cert_sign=True,
756                                         crl_sign=True, encipher_only=False, decipher_only=False), critical=True)
757            .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
758            .sign(key, hashes.SHA256()))
759    cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
760    key_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL,
761                                serialization.NoEncryption()).decode()
762    return cert_pem, key_pem

(certificate_pem, private_key_pem) of a fresh self-signed RSA certification authority (CA:TRUE, keyCertSign / cRLSign, subject key identifier), for labs that hand a ready CA to the students (the DNS lab's ca.tp signing the DoT / DoH certificate).

def generate_server_cert_pem( ca_cert_pem: str, ca_key_pem: str, hostname: str, days: int = 365, key_size: int = 2048, extra_names: tuple[str, ...] = ()) -> tuple[str, str]:
765def generate_server_cert_pem(ca_cert_pem: str, ca_key_pem: str, hostname: str, days: int = 365,
766                             key_size: int = 2048, extra_names: tuple[str, ...] = ()) -> tuple[str, str]:
767    """``(certificate_pem, private_key_pem)`` of a fresh RSA server certificate for *hostname*
768    (CN and ``subjectAltName`` dNSName, plus *extra_names*), signed by the CA of
769    :func:`generate_ca_pem`: what a reference solution installs on a TLS server (the LDAP lab's
770    ``ldap1.corp.lan``) without running ``openssl`` in the container."""
771    from datetime import datetime, timedelta, timezone
772    from cryptography import x509
773    from cryptography.hazmat.primitives import hashes, serialization
774    from cryptography.hazmat.primitives.asymmetric import rsa
775    from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
776    ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode())
777    ca_key = serialization.load_pem_private_key(ca_key_pem.encode(), password=None)
778    key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
779    now = datetime.now(timezone.utc) - timedelta(days=1)
780    names = [x509.DNSName(hostname)] + [x509.DNSName(n) for n in extra_names]
781    cert = (x509.CertificateBuilder()
782            .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
783            .issuer_name(ca_cert.subject).public_key(key.public_key())
784            .serial_number(x509.random_serial_number()).not_valid_before(now)
785            .not_valid_after(now + timedelta(days=days))
786            .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
787            .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=True,
788                                         data_encipherment=False, key_agreement=False, key_cert_sign=False,
789                                         crl_sign=False, encipher_only=False, decipher_only=False), critical=True)
790            .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
791            .add_extension(x509.SubjectAlternativeName(names), critical=False)
792            .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
793            .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
794            .sign(ca_key, hashes.SHA256()))
795    cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
796    key_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL,
797                                serialization.NoEncryption()).decode()
798    return cert_pem, key_pem

(certificate_pem, private_key_pem) of a fresh RSA server certificate for hostname (CN and subjectAltName dNSName, plus extra_names), signed by the CA of generate_ca_pem(): what a reference solution installs on a TLS server (the LDAP lab's ldap1.corp.lan) without running openssl in the container.

def parse_stepca_config(text: str) -> dict | None:
801def parse_stepca_config(text: str) -> dict | None:
802    """Summary of a step-ca ``ca.json``: ``{'address', 'dns_names', 'root', 'provisioners':
803    [{'name', 'type'}, ...]}``; ``None`` when the text is not a JSON object."""
804    try:
805        cfg = json.loads(text or '')
806    except (ValueError, TypeError):
807        return None
808    if not isinstance(cfg, dict):
809        return None
810    provisioners = (cfg.get('authority') or {}).get('provisioners') or []
811    return {
812        'address': cfg.get('address', ''),
813        'dns_names': list(cfg.get('dnsNames') or []),
814        'root': cfg.get('root', ''),
815        'provisioners': [{'name': p.get('name', ''), 'type': p.get('type', '')}
816                         for p in provisioners if isinstance(p, dict)],
817    }

Summary of a step-ca ca.json: {'address', 'dns_names', 'root', 'provisioners': [{'name', 'type'}, ...]}; None when the text is not a JSON object.

def get_stepca_config( grade: SRE.lib_sre.Grade0, machine_name: str, config_file: str = '/home/ca/.step/config/ca.json', step: int = 1) -> dict | None:
820def get_stepca_config(grade: Grade0, machine_name: str,
821                      config_file: str = '/home/ca/.step/config/ca.json',
822                      step: int = 1) -> dict | None:
823    """Parsed step-ca configuration of machine_name (see :func:`parse_stepca_config`)."""
824    out, code = grade.test(machine_name=machine_name, command=f"cat {shlex.quote(config_file)}",
825                           step=step, allow_error=True)
826    if code != 0:
827        return None
828    return parse_stepca_config(out)

Parsed step-ca configuration of machine_name (see parse_stepca_config()).