Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ htmlcov/
dist/
build/
*.egg

# Local Claude Code session artifacts (reviews, plans)
.claude/
39 changes: 38 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

---

## [0.7.1] — 2026-08-17

Resolves all findings of the post-merge v0.7.0 review (PR #10 comment).

### Changed
- **Single-probe certificate fetch**: `probe_tls` no longer opens a second
connection for PQC-02. The primary `openssl s_client` probe now runs
without `-brief` — the full output carries the leaf-certificate PEM
alongside the protocol and negotiated-group lines — and `_run_openssl`
extracts the certificate from that same output. One TCP+TLS handshake
saved per TLS/STARTTLS assessment (measured 0.3–0.6 s); also removes the
`-ign_eof`/`QUIT` linger risk from the assessment hot path.
`_parse_openssl_output` now accepts both the `-brief` label
(`Protocol version:`) and the non-brief labels (`Protocol:`, and the
indented `Protocol :` SSL-Session variant), confirmed against
OpenSSL 3.6.3. Report contents are unchanged.
### Removed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `fetch_certificate()` and `probe_raw()`: with the single-probe fetch they
had no callers left, and each was a second public network path beside
`probe_tls` — the sole I/O boundary the coding guidelines require. Callers
needing certificate details read them from the `probe_tls` result.

### Fixed
- `--json` output could be **invalid JSON**: the CLI printed it through the
Rich console, which hard-wraps at the terminal width (80 columns when
piped), inserting raw newlines inside JSON strings. Latent since the JSON
flag existed (any reason string longer than the terminal width triggered
it, e.g. the `key_exchange` FAIL reason); v0.7.0's longer
`certificate_key` reasons made it hit on virtually every TLS assessment.
`_print_json` now prints with `soft_wrap=True, markup=False`. Found while
verifying this release; regression-tested with a 400-character reason.
- `certificate_key` check: an RSA key with undeterminable size now renders
`value="RSA-unknown"` instead of `"RSA-None"` (still FAIL).

---

## [0.7.0] — 2026-08-17

### Added
Expand Down Expand Up @@ -374,7 +410,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

---

[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.0...HEAD
[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.1...HEAD
[0.7.1]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.4...v0.7.0
[0.6.4]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.2...v0.6.3
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ $ quantumvalidator check cloudflare.com
```

![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
![Tests](https://img.shields.io/badge/tests-302%20passing-brightgreen)
![Tests](https://img.shields.io/badge/tests-288%20passing-brightgreen)
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
![License](https://img.shields.io/badge/license-GPLv3-lightgrey)

Expand Down Expand Up @@ -74,10 +74,10 @@ the check table:
| `certificate_key` | TLS | RSA < 3072 bits, EC curve outside P-256/P-384/P-521/Brainpool ≥ 256, or DSA | CNSA 2.0, BSI TR-02102-2, NIST SP 800-186, FIPS 186-5 |
| `host_key_algorithms` | SSH | `ssh-dss` (DSA, withdrawn) or `ssh-rsa` (SHA-1 signatures) advertised | RFC 8332, FIPS 186-5, BSI TR-02102-4 |

The certificate is retrieved with a second `openssl s_client` probe (the primary probe runs
with `-brief`, which suppresses certificate output) and parsed with
[pyca/cryptography](https://cryptography.io/). The SSH host-key list is read from the same
KEXINIT packet as the KEX algorithms — no extra connection.
The certificate is extracted from the same `openssl s_client` probe used for the
key-exchange check and parsed with [pyca/cryptography](https://cryptography.io/). The SSH
host-key list is read from the same KEXINIT packet as the KEX algorithms. Neither check
opens an extra connection.

---

Expand Down Expand Up @@ -316,7 +316,7 @@ pytest tests/test_tls_utils.py
pytest tests/test_assessor.py::TestAssessHttps -v
```

The test suite has **302 tests** and maintains **100% statement coverage**.
The test suite has **288 tests** and maintains **100% statement coverage**.

All network I/O (`openssl s_client` subprocess) is mocked at the `probe_tls` boundary —
no test touches a real server or the internet.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "quantumvalidator"
version = "0.7.0"
version = "0.7.1"
description = "Quantum-safe cryptography validator — TLS, STARTTLS, and SSH post-quantum readiness assessment"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion quantumvalidator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
try:
__version__ = version("quantumvalidator")
except PackageNotFoundError: # pragma: no cover
__version__ = "0.7.0"
__version__ = "0.7.1"

_logging.getLogger("quantumvalidator").addHandler(_logging.NullHandler())
del _logging
Expand Down
5 changes: 4 additions & 1 deletion quantumvalidator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ def _print_json(report: QuantumReport) -> None:
for c in report.checks
],
}
console.print(json.dumps(out, indent=2))
# soft_wrap: Rich otherwise hard-wraps at terminal width (80 when piped),
# inserting raw newlines inside JSON strings — invalid JSON downstream.
# markup=False: JSON brackets must never be parsed as Rich markup tags.
console.print(json.dumps(out, indent=2), soft_wrap=True, markup=False)


if __name__ == "__main__": # pragma: no cover
Expand Down
184 changes: 43 additions & 141 deletions quantumvalidator/tls_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,89 +100,6 @@ def ok(self) -> bool:
)


def probe_raw(
host: str,
port: int,
*,
starttls: str | None = None,
sni_hostname: str | None = None,
timeout: float = 10.0,
) -> str | None:
"""Run ``openssl s_client`` and return the combined stdout+stderr output.

Unlike :func:`probe_tls`, this function does not parse the output — it
returns the raw text so callers can extract protocol-specific fields such
as ``Max Early Data:`` (TLS 1.3 0-RTT, RFC 8446 §8).

Returns ``None`` for the following failure conditions (callers cannot
distinguish between them):

- ``openssl`` binary is not on ``PATH``
- ``host`` or ``port`` fail validation
- subprocess times out (``timeout + 2`` seconds)
- ``OSError`` from the subprocess (e.g. connection refused)

:param host: Hostname or IP to connect to.
:param port: TCP port.
:param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``), or ``None``
for raw TLS. Must be one of the modes recognised by openssl s_client.
:param sni_hostname: Hostname to send as TLS SNI via ``-servername``, or ``None``.
:param timeout: Connection timeout in seconds; the subprocess is given
``timeout + 2`` seconds to allow TLS handshake completion.
:returns: Combined stdout+stderr from ``openssl s_client``, or ``None`` on failure.
:rtype: str | None
:raises ValueError: If ``starttls`` is not a recognised openssl STARTTLS mode.
"""
if starttls is not None and starttls not in _VALID_STARTTLS:
raise ValueError(
f"Invalid starttls mode {starttls!r}. "
f"Must be one of: {', '.join(sorted(_VALID_STARTTLS))}"
)

ok, _ = check_openssl()
if not ok:
return None

try:
_validate_target(host, port)
except ValueError:
return None

try:
addr = ipaddress.ip_address(host)
connect_str = f"[{host}]:{port}" if addr.version == 6 else f"{host}:{port}"
except ValueError:
connect_str = f"{host}:{port}"

groups_str = ":".join(PROBE_GROUPS)
cmd = [
OPENSSL_BINARY,
"s_client",
"-connect", connect_str,
"-groups", groups_str,
"-ign_eof",
]
if starttls is not None:
cmd.extend(["-starttls", starttls])
if sni_hostname:
cmd.extend(["-servername", sni_hostname])

logger.debug(
"probe_raw %s:%d starttls=%s sni=%s — cmd: %s",
host, port, starttls, sni_hostname, " ".join(cmd),
)
try:
proc = subprocess.run(
cmd,
input=b"QUIT\r\n",
capture_output=True,
timeout=timeout + 2,
)
return (proc.stdout + proc.stderr).decode("utf-8", errors="replace")
except (subprocess.TimeoutExpired, OSError):
return None


_PEM_CERT_RE: re.Pattern[str] = re.compile(
r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
re.DOTALL,
Expand Down Expand Up @@ -231,38 +148,6 @@ def _parse_certificate(pem: str) -> CertificateInfo | None:
return CertificateInfo(key_type="unknown", key_size=None, curve=None)


def fetch_certificate(
host: str,
port: int,
*,
starttls: str | None = None,
timeout: float = 10.0,
) -> CertificateInfo | None:
"""Fetch the leaf certificate of *host*:*port* and return its key details.

Runs a second ``openssl s_client`` probe without ``-brief`` (via
:func:`probe_raw`) because ``-brief`` suppresses certificate output, then
parses the PEM block with pyca/cryptography.

:param host: Hostname or IP to connect to.
:param port: TCP port.
:param starttls: openssl ``-starttls`` mode, or ``None`` for raw TLS.
:param timeout: Connection timeout in seconds.
:returns: Certificate key details, or ``None`` if the certificate could
not be retrieved or parsed.
:rtype: CertificateInfo | None
"""
output = probe_raw(host, port, starttls=starttls, timeout=timeout)
if output is None:
logger.warning("Certificate fetch failed for %s:%d", host, port)
return None
pem = _extract_pem_cert(output)
if pem is None:
logger.warning("No certificate in s_client output for %s:%d", host, port)
return None
return _parse_certificate(pem)


def probe_tls(
host: str,
port: int,
Expand Down Expand Up @@ -302,23 +187,17 @@ def probe_tls(
return _probe_ssh(host, port, timeout)

if detected == "ftp":
result = _probe_ftp(host, port, timeout)
else:
if detected:
logger.info(
"Banner detected '%s' for %s:%d — probing with -starttls %s",
detected, host, port, detected,
)
result = _run_openssl(host, port, starttls=detected, timeout=timeout)
if detected:
result.detected_starttls = detected

# PQC-02: fetch the leaf certificate with a second, non--brief probe.
# Failure here never fails the assessment — certificate stays None.
if result.ok:
result.certificate = fetch_certificate(
host, port, starttls=result.detected_starttls, timeout=timeout
return _probe_ftp(host, port, timeout)

if detected:
logger.info(
"Banner detected '%s' for %s:%d — probing with -starttls %s",
detected, host, port, detected,
)

result = _run_openssl(host, port, starttls=detected, timeout=timeout)
if detected:
result.detected_starttls = detected
return result


Expand All @@ -337,10 +216,12 @@ def _build_cmd(host: str, port: int, starttls: str | None) -> list[str]:
connect_str = f"[{host}]:{port}" if addr.version == 6 else f"{host}:{port}"
except ValueError:
connect_str = f"{host}:{port}"
# No -brief: the full output carries the leaf-certificate PEM (PQC-02)
# alongside the protocol and negotiated-group lines, so a single probe
# serves both the key-exchange check and the certificate check.
cmd = [
OPENSSL_BINARY,
"s_client",
"-brief",
"-groups", groups_str,
"-connect", connect_str,
]
Expand All @@ -361,7 +242,8 @@ def _run_openssl(
:param port: TCP port.
:param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp'``/``'xmpp'``), or ``None`` for raw TLS.
:param timeout: Connection timeout in seconds.
:returns: Probe result; ``raw_output`` always populated from subprocess output.
:returns: Probe result; ``certificate`` populated when the output
contains a parseable leaf-certificate PEM.
:rtype: TLSProbeResult
"""
cmd = _build_cmd(host, port, starttls)
Expand Down Expand Up @@ -411,15 +293,24 @@ def _run_openssl(
error=error,
)

# PQC-02: the same (non--brief) output carries the leaf-certificate PEM.
# Parse failure leaves certificate None — never an error.
pem = _extract_pem_cert(combined)
certificate = _parse_certificate(pem) if pem else None
if certificate is None:
logger.warning("No parseable certificate in s_client output for %s:%d", host, port)

logger.info(
"Probe complete for %s:%d — version=%s group=%s",
"Probe complete for %s:%d — version=%s group=%s cert=%s",
host, port, tls_version, negotiated_group,
certificate.key_type if certificate else None,
)
return TLSProbeResult(
host=host,
port=port,
tls_version=tls_version,
negotiated_group=negotiated_group,
certificate=certificate,
)


Expand Down Expand Up @@ -686,12 +577,20 @@ def _fingerprint_banner(output: str) -> str | None:
return None


_PROTOCOL_LINE_RE: re.Pattern[str] = re.compile(r"^Protocol(?: version)?\s*:\s*(\S+)")


def _parse_openssl_output(output: str) -> tuple[str | None, str | None]:
"""Parse ``openssl s_client -brief`` output.
"""Parse ``openssl s_client`` output (with or without ``-brief``).

Confirmed output formats (OpenSSL 3.6.3, 2026-08-17):
-brief: Protocol version: TLSv1.3
no -brief: Protocol: TLSv1.3 (summary line)
Protocol : TLSv1.2 (SSL-Session block, indented)
both: Negotiated TLS1.3 group: X25519MLKEM768

Confirmed output format (OpenSSL 3.6, 2026-04-28):
Protocol version: TLSv1.3
Negotiated TLS1.3 group: X25519MLKEM768
``ALPN protocol:`` lines do not match — the pattern is anchored at the
start of the stripped line.

:param output: Combined stdout+stderr from the subprocess.
:returns: ``(tls_version, negotiated_group)`` — either may be None.
Expand All @@ -702,9 +601,12 @@ def _parse_openssl_output(output: str) -> tuple[str | None, str | None]:

for line in output.splitlines():
stripped = line.strip()
if stripped.startswith("Protocol version:"):
tls_version = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Negotiated TLS1.3 group:"):
if tls_version is None:
m = _PROTOCOL_LINE_RE.match(stripped)
if m:
tls_version = m.group(1)
continue
if stripped.startswith("Negotiated TLS1.3 group:"):
negotiated_group = stripped.split(":", 1)[1].strip()
if tls_version and negotiated_group:
break
Expand Down
6 changes: 5 additions & 1 deletion quantumvalidator/verdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ def _build_certificate_check(certificate: CertificateInfo | None) -> CheckResult
)

if certificate.key_type == "RSA":
value = f"RSA-{certificate.key_size}"
value = (
f"RSA-{certificate.key_size}"
if certificate.key_size is not None
else "RSA-unknown"
)
if certificate.key_size is not None and certificate.key_size >= RSA_MIN_KEY_SIZE:
return CheckResult(
name="certificate_key",
Expand Down
Loading