Skip to content

Detect post-quantum key exchange and fix certificate reporting - #2

Merged
thebenignhacker merged 2 commits into
mainfrom
fix/pqc-key-exchange-detection
Jul 27, 2026
Merged

thebenignhacker merged 2 commits into
mainfrom
fix/pqc-key-exchange-detection

Conversation

@thebenignhacker

Copy link
Copy Markdown
Member

Fixes #1.

The reported problem

@manel1874 reported in January that tls-analyzer never reports a quantum-safe
connection, and pointed at the two exact lines responsible. That was correct.

parseKeyExchange returned a hardcoded classical X25519 for every TLS 1.3
connection, and quantumSafe was a constant false in three separate places.
A server negotiating X25519MLKEM768 was reported as quantum vulnerable, so no
scan could ever produce a quantum-safe result.

The code comment claimed hybrid detection "requires raw handshake access or
TLS extension parsing" and a custom TLS stack. That is no longer true. Go 1.24
offers X25519MLKEM768 by default, and Go 1.25 exposes the negotiated group as
tls.ConnectionState.CurveID.

Before, against the reporter's own endpoint:

keyExchanges: [ { "name": "X25519", "type": "classical", "quantumSafe": false } ]

After:

X25519MLKEM768   type=hybrid     quantumSafe=true  negotiated=true  pqc=ML-KEM-768
secp521r1        type=classical  quantumSafe=false negotiated=false
secp384r1        type=classical  quantumSafe=false negotiated=false
X25519           type=classical  quantumSafe=false negotiated=false
secp256r1        type=classical  quantumSafe=false negotiated=false

Verified against openssl s_client, which reports
Negotiated TLS1.3 group: X25519MLKEM768 for the same host.

Answers to the reporter's questions

  1. Nothing was wrong on their side. The tool was.
  2. Yes. X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024 are
    detected as of this change.
  3. Reading handshakes from external tools is not needed for this, since the Go
    standard library now reports the negotiated group directly.

Beyond the report

Scans now also enumerate which groups the server supports, not only the one this
client negotiated, since readiness is a question about the server rather than
about our client's preferences.

Four further defects surfaced while reproducing this:

  • Certificate fingerprints were not hashes. sha256Fingerprint returned
    hex(der[:20])+"...". Both "fingerprints" were prefixes of the certificate,
    started with the same DER header bytes, and matched no real fingerprint. They
    now agree with openssl x509 -fingerprint byte for byte.
  • EC certificates reported publicKeyBits: 0, because the type switch
    matched only types implementing Size() int.
  • TLS_AES_128_GCM_SHA256 reported 256 bits, because Contains(name, "256")
    matched the SHA256 suffix first.
  • Scan output was nondeterministic. Protocol probes ran concurrently and
    appended in completion order while the code assumed declaration order, so the
    list order and the preferred marker varied between identical runs.

Reports also recorded scanner version 0.1.0 regardless of the installed
release, and the CBOM output failed CycloneDX 1.6 validation on a primitive
value outside the schema enum plus two empty bom-ref strings.

Scoring change

Quantum risk is now weighted 80/20 toward key exchange, from 60/40.

Under the old weighting a server running hybrid ML-KEM with a classical
certificate scored 48/100, graded HIGH, and was told to "begin hybrid PQC
implementation" that it had already completed. No publicly trusted CA issues
ML-DSA certificates, so the certificate dimension is not remediable by any
operator at any price, and it structurally capped every real site. Key exchange
is also the only dimension that applies retroactively, through
harvest-now-decrypt-later. The same server now scores 64/100 and grades MEDIUM.

Testing

New regression tests cover group mapping, cipher key sizes, fingerprint
correctness, public key sizing, merge determinism, CBOM schema conformance, and
the advice regression. Verified they fail against the pre-fix code rather than
passing vacuously. go test -race ./... is clean.

Requires Go 1.25 for ConnectionState.CurveID. CI moved from Go 1.21, which had
also drifted from the 1.23 declared in go.mod.

The analyzer could never report a quantum-safe connection. parseKeyExchange
returned a hardcoded classical X25519 for every TLS 1.3 connection and
quantumSafe was a constant false in three places, so a server negotiating
X25519MLKEM768 was reported as quantum vulnerable. Read the negotiated group
from tls.ConnectionState.CurveID instead, and recognize X25519MLKEM768,
SecP256r1MLKEM768 and SecP384r1MLKEM1024. Key exchange results now also list
the groups a server supports, not only the one this client negotiated.

Certificate fingerprints were not hashes. sha256Fingerprint returned
hex(der[:20])+"..." and sha1Fingerprint returned hex(der[:10])+"...", so both
values were prefixes of the certificate, began with the same DER header bytes
and matched no real fingerprint. They are now genuine digests that agree with
openssl x509 -fingerprint.

Also fixed: elliptic curve certificates reported publicKeyBits 0 because the
type switch matched only types implementing Size() int; TLS_AES_128_GCM_SHA256
reported 256 bits because Contains(name, "256") matched the SHA256 suffix;
concurrent protocol probes appended in completion order while the code assumed
declaration order, so output varied between identical runs; generated reports
recorded scanner version 0.1.0 regardless of the installed release; and CBOM
output failed CycloneDX 1.6 validation on a primitive outside the enum and two
empty bom-ref strings.

Quantum risk is now weighted 80/20 toward key exchange rather than 60/40.
Under the old weighting a server running hybrid ML-KEM with a classical
certificate scored 48/100, graded HIGH and was told to begin hybrid PQC
implementation, work it had already completed. No publicly trusted CA issues
ML-DSA certificates, so that dimension is not remediable by any operator today,
and key exchange is the only dimension that applies retroactively.

Requires Go 1.25 for tls.ConnectionState.CurveID. CI moved from Go 1.21.

Fixes #1
@thebenignhacker
thebenignhacker merged commit 11cb4e9 into main Jul 27, 2026
2 checks passed
@thebenignhacker
thebenignhacker deleted the fix/pqc-key-exchange-detection branch July 27, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tls analyzer for pq connections always quantum vulnerable

1 participant