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
49 changes: 48 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,52 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

---

## [0.7.0] — 2026-08-17

### Added
- **PQC-02 — certificate algorithm tracking**: new `certificate_key` check on
every TLS/STARTTLS assessment. The leaf 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.
RSA keys below 3072 bits (CNSA 2.0 / BSI TR-02102-2), EC curves outside
P-256/P-384/P-521/Brainpool ≥ 256 (NIST SP 800-186), and DSA keys
(withdrawn, FIPS 186-5) are reported as `FAIL`; Ed25519/Ed448 pass. If the
certificate cannot be retrieved or parsed the check is `INFO`, never
`ERROR` — a failed best-effort fetch must not flip the CLI to exit code 2
or create a platform finding.
- **PQC-03 — SSH host-key algorithm check**: new `host_key_algorithms` check
on every SSH assessment. The `server_host_key_algorithms` name-list is
parsed from the same KEXINIT packet already read for the KEX check
(RFC 4253 §7.1) — no extra connection. Advertising `ssh-dss` (DSA,
withdrawn by FIPS 186-5) or `ssh-rsa` (SHA-1 signatures, superseded by
rsa-sha2-256/512 per RFC 8332), including their `*[email protected]`
variants, is reported as `FAIL`.
- `tls_utils.CertificateInfo` dataclass and `tls_utils.fetch_certificate()`;
`TLSProbeResult` gained optional `certificate` and
`ssh_host_key_algorithms` fields.
- `constants`: `RSA_MIN_KEY_SIZE`, `EC_APPROVED_CURVES`, `EC_CURVE_DISPLAY`,
`DEPRECATED_SSH_HOST_KEY_ALGORITHMS`.
- New runtime dependency: `cryptography>=42` (X.509 parsing). Chosen over
scraping `openssl x509 -text` output, which is fragile across OpenSSL
versions and locales.

### Changed
- `verdict.build_checks()` now accepts keyword-only `certificate` and
`ssh_host_key_algorithms` arguments and returns **three** checks instead of
two for both TLS and SSH probes.
- Successful TLS assessments now open one additional connection (the
certificate fetch). SSH assessments are unchanged (single connection).

### Unchanged by design (backend contract)
- The SAFE/UNSAFE verdict still tracks PQC key-exchange readiness (PQC-01)
only — `certificate_key` / `host_key_algorithms` failures do **not** flip
the verdict or the CLI exit code. `Status` and `Verdict` enums gained no
new members; existing check names are untouched. The platform's
severity-vocabulary mapping (IDR-018) keeps working without backend
changes; the two new check names simply appear as additional findings.

---

## [0.6.4] — 2026-08-12

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

---

[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.4...HEAD
[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.0...HEAD
[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
[0.6.2]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.1...v0.6.2
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
FTP/STARTTLS, LMTP/STARTTLS, NNTP/STARTTLS, ManageSieve/STARTTLS, and SSH endpoints to detect
whether a PQC hybrid key exchange (ML-KEM) was negotiated, returning a binary **SAFE** / **UNSAFE**
verdict aligned with NSA CNSA 2.0, BSI TR-02102-2 (TLS), and BSI TR-02102-4 (SSH).
It also tracks classical algorithm hygiene: certificate key algorithms (RSA size, EC curve)
for TLS services, and deprecated host-key algorithms (DSA, RSA-SHA1) for SSH services.

```bash
$ quantumvalidator check cloudflare.com
```

![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
![Tests](https://img.shields.io/badge/tests-247%20passing-brightgreen)
![Tests](https://img.shields.io/badge/tests-302%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 @@ -61,6 +63,22 @@ The server must advertise at least one of these algorithms in its KEXINIT to rec
verdict. `[email protected]` (NTRU) is intentionally excluded — it uses a
non-NIST-selected algorithm.

### Classical algorithm hygiene checks

Alongside PQC key-exchange readiness, two additional checks flag weak classical algorithms.
Their failures do **not** change the SAFE/UNSAFE verdict — they surface as `FAIL` entries in
the check table:

| Check | Protocol | FAIL condition | Standard |
|---|---|---|---|
| `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.

---

## Standards
Expand All @@ -82,7 +100,7 @@ non-NIST-selected algorithm.
- **OpenSSL** ≥ 3.5 binary on PATH — required for native PQC hybrid group negotiation
- Debian/Ubuntu: `apt install openssl`
- macOS: `brew install openssl`
- `rich` ≥ 13.7 and `typer` ≥ 0.12 (installed automatically via pip)
- `cryptography` ≥ 42, `rich` ≥ 13.7, and `typer` ≥ 0.12 (installed automatically via pip)

---

Expand Down Expand Up @@ -235,7 +253,8 @@ if report.is_safe:

The verdict is binary by design — classical certificate signatures are not yet a practical
threat (no public CA issues ML-DSA/SLH-DSA certificates), so the signal focuses solely on
key exchange.
key exchange. The `certificate_key` and `host_key_algorithms` checks report classical
algorithm weaknesses independently, without changing the verdict.

---

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

The test suite has **247 tests** and maintains **100% statement coverage**.
The test suite has **302 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
3 changes: 2 additions & 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.6.4"
version = "0.7.0"
description = "Quantum-safe cryptography validator — TLS, STARTTLS, and SSH post-quantum readiness assessment"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -41,6 +41,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"cryptography>=42",
"rich>=13.7",
"typer>=0.12",
]
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.6.4"
__version__ = "0.7.0"

_logging.getLogger("quantumvalidator").addHandler(_logging.NullHandler())
del _logging
Expand Down
12 changes: 11 additions & 1 deletion quantumvalidator/assessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ def assess(
The probe auto-detects STARTTLS mode (smtp/imap/pop3/ftp/lmtp/nntp/sieve)
via banner fingerprinting, and SSH via ``SSH-2.0-`` banner.

Besides PQC key-exchange readiness, the report includes classical
algorithm hygiene checks: ``certificate_key`` (RSA size / EC curve of the
TLS leaf certificate) and ``host_key_algorithms`` (deprecated SSH
host-key algorithms). Their failures do not affect the verdict.

:param target: Hostname or IP address to probe.
:param port: TCP port override; defaults to 443.
:param timeout: Connection timeout in seconds.
Expand Down Expand Up @@ -68,7 +73,12 @@ def assess(
]
verdict = Verdict.UNSAFE
else:
checks = build_checks(result.tls_version, result.negotiated_group)
checks = build_checks(
result.tls_version,
result.negotiated_group,
certificate=result.certificate,
ssh_host_key_algorithms=result.ssh_host_key_algorithms,
)
verdict = determine_verdict(result.tls_version, result.negotiated_group)

report = QuantumReport(
Expand Down
4 changes: 3 additions & 1 deletion quantumvalidator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"Auto-detects STARTTLS protocols (SMTP, IMAP, POP3, FTP, LMTP, NNTP, Sieve) "
"via banner fingerprinting, and SSH via SSH-2.0- banner. "
"Checks whether the service negotiates a PQC hybrid key exchange group (ML-KEM) "
"as required by CNSA 2.0 and BSI TR-02102-2/TR-02102-4."
"as required by CNSA 2.0 and BSI TR-02102-2/TR-02102-4. "
"Also tracks certificate key algorithms (RSA size, EC curve) and "
"deprecated SSH host-key algorithms (DSA, RSA-SHA1)."
),
add_completion=False,
)
Expand Down
41 changes: 41 additions & 0 deletions quantumvalidator/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,47 @@ class GroupInfo:
name for name, g in SSH_PQC_GROUPS.items() if g.safe
)

# Certificate-key policy (PQC-02).
# RSA below 3072 bits is under the CNSA 2.0 floor and below the
# BSI TR-02102-2 recommendation of >= 3000 bits.
RSA_MIN_KEY_SIZE: int = 3072

# Curves approved for TLS server certificates: NIST P-256/P-384/P-521
# (NIST SP 800-186) plus the >= 256-bit Brainpool curves accepted by
# BSI TR-02102-2. Keys are the pyca/cryptography ``curve.name`` values.
EC_APPROVED_CURVES: frozenset[str] = frozenset(
{
"secp256r1",
"secp384r1",
"secp521r1",
"brainpoolP256r1",
"brainpoolP384r1",
"brainpoolP512r1",
}
)

# pyca/cryptography curve.name → common display name. Curves absent from
# this map are displayed under their SECG name (e.g. 'secp256k1').
EC_CURVE_DISPLAY: dict[str, str] = {
"secp192r1": "P-192",
"secp224r1": "P-224",
"secp256r1": "P-256",
"secp384r1": "P-384",
"secp521r1": "P-521",
}

# SSH host-key algorithms rejected by PQC-03: ssh-dss is DSA (withdrawn by
# NIST FIPS 186-5); ssh-rsa signs with SHA-1 (superseded by rsa-sha2-256/512,
# RFC 8332). The *-cert-v01 variants sign with the same primitives.
DEPRECATED_SSH_HOST_KEY_ALGORITHMS: frozenset[str] = frozenset(
{
"ssh-dss",
"ssh-rsa",
"[email protected]",
"[email protected]",
}
)

DEFAULT_TIMEOUT: float = 10.0
DEFAULT_PORT_HTTPS: int = 443

Expand Down
Loading