polycratia

A classical handshake is only a finding if you recorded what you offered

· 10 min read

No Go repository decides whether its production traffic is protected by a post-quantum key exchange. That gets settled at handshake time, per connection, by the TLS stack inside the binary and by whatever terminates TLS in front of it. So a source scan is silent on the first question a post-quantum migration plan has to answer. And a probe that breaks the silence can be wrong in a way nobody notices: a classical result is worthless as evidence unless you also recorded which groups you put on the table.

I hit this building cbomscope, a tool that inventories the cryptography a Go project uses and writes it out as a CycloneDX 1.6 cryptography bill of materials. The static half was the easy half. The probe exists because the static half structurally cannot answer the key exchange question, and because the naive version of the probe produces findings that look identical whether or not they mean anything.

The question the repository cannot answer#

Grep a typical Go service for its key exchange configuration and you will usually find nothing, because nothing is there. The negotiated group is a function of the standard library version the binary was built against, of a GODEBUG setting in the environment, and most often of a reverse proxy, load balancer or CDN that terminates TLS before your process ever sees a byte.

So the scanner behaves correctly and tells you nothing useful:

console
$ cbomscope scan .
POSTURE          ASSET    WHERE                      WHY
quantum_reduced  SHA-256  internal/cbom/cbom.go:137  256-bit digest: quantum collision search reduces the margin; SHA-384 or larger is the usual answer

1 asset(s): quantum_reduced=1

That is an honest reading of the source tree. It is also an incomplete reading of the deployment, and the gap runs in both directions. The scan cannot see that an edge has already moved to a hybrid post-quantum group, and it cannot see that it has not. I ended up putting it in the README this way: source code cannot tell you that this deployment has already moved its key exchange to a hybrid post-quantum group, because no source in the repository chose it.

The second source, and the way it quietly lies#

The fix is a second source: connect to the endpoint and report what the handshake actually used.

console
$ cbomscope probe cloudflare.com:443
POSTURE             ASSET                   WHERE               WHY
quantum_vulnerable  ECDSA-P-256             cloudflare.com:443  Shor's algorithm solves the underlying elliptic-curve discrete logarithm
quantum_reduced     TLS_AES_128_GCM_SHA256  cloudflare.com:443  Grover halves this to about 64 bits of quantum work; 256-bit keys are the usual answer
hybrid              X25519MLKEM768          cloudflare.com:443  classical and post-quantum key exchange combined: secure if either half holds
not_applicable      TLS 1.3                 cloudflare.com:443  a protocol version has no quantum posture of its own: see the key exchange and cipher it negotiated

Now picture the same table with X25519 on the hybrid line instead, classified as classical. Two completely different worlds print that identical row. In the first, the endpoint was offered a post-quantum group and turned it down, which is a real finding and sits near the top of a migration plan. In the second, the probe never offered one, and the row says nothing about the endpoint at all. It is a fact about my client configuration wearing the costume of a fact about their server.

This is the same distinction I have had to defend over and over in payment integrations: declined and never asked are different states, and only one of them is a fact about the counterparty. Collapsing them is how an inventory ends up confidently wrong.

Which is why the offered list is written out in the source rather than inherited:

go
// offeredGroups is what the probe offers, hybrid first. It is written out here
// rather than left to the standard library's default because the default moves
// between releases and can be switched off by a GODEBUG setting: the absence of
// post-quantum key exchange is only an endpoint's answer if a post-quantum
// group was asked for.
var offeredGroups = []tls.CurveID{
    tls.X25519MLKEM768,
    tls.X25519,
    tls.CurveP256,
    tls.CurveP384,
    tls.CurveP521,
}

Leaving this to the default would make the tool's verdict depend on the Go release it was compiled with and on an environment variable set three layers away in a container image. The same binary, run on two machines, would report two different postures for the same endpoint, and both would print as findings...

Offer deliberately, then publish the offer#

Setting the list is half of it. The other half is that the list travels with the answer, so a reader of a single finding can tell a refusal from an omission without knowing how the run was configured.

go
// Offered names every key exchange group the probe puts on the table.
func Offered() []string {
    out := make([]string, 0, len(offeredGroups))
    for _, id := range offeredGroups {
        out = append(out, groups[id].name)
    }
    return out
}

// OfferedPostQuantum names the post-quantum groups among them. A classical
// result is read against this list: these are what the endpoint declined.
func OfferedPostQuantum() []string {
    var out []string
    for _, id := range offeredGroups {
        if g := groups[id]; g.postQuantum {
            out = append(out, g.name)
        }
    }
    return out
}

The result type carries it as a field, not as a log line:

go
type Result struct {
    Address string `json:"address"`
    Version string `json:"version"`
    // Group is the negotiated key exchange group, empty when the handshake
    // reported none.
    Group       string      `json:"group,omitempty"`
    KeyExchange KeyExchange `json:"key_exchange"`
    // Offered are the groups the probe put on the table. Without them a
    // classical answer is not a finding about the endpoint: it reads the same as
    // a probe that never asked for anything better.
    Offered []string `json:"offered"`
    Signature string       `json:"signature,omitempty"`
    Assets  []asset.Asset `json:"assets"`
}

And the per-asset evidence string names the menu next to the choice, because an asset pulled out of the result and dropped into a bill of materials loses the surrounding context:

go
Evidence: fmt.Sprintf("negotiated key exchange group (codepoint 0x%04x), chosen from %s",
    uint16(state.CurveID), strings.Join(result.Offered, ", ")),

The codepoint is in there for the groups no name in my build covers. A handshake can hand back a curve ID the binary has no constant for, and unknown with a number is a lead; unknown with nothing is a dead end.

The wire names things its own way#

A probe is only as good as its ability to recognise what came back, and hybrid groups are the one family that defeats the obvious matching strategy. The classification table matches a family as a prefix of the uppercased algorithm name, longest match winning, so that SHA-1 is not read as SHA. A hybrid group is spelled as a concatenation of its two halves, and no prefix finds it. Worse, both halves match rows of their own, so a prefix match would classify a hybrid group as whichever half it happened to hit.

go
var (
    postQuantumHalves = []string{"MLKEM", "ML-KEM", "KYBER"}
    classicalHalves   = []string{"X25519", "X448", "SECP", "P256", "P-256", "P384", "P-384", "P521", "P-521", "ECDH"}
)

{
    Family:    "X25519MLKEM768",
    Posture:   asset.Hybrid,
    Rationale: "classical and post-quantum key exchange combined: secure if either half holds",
    Citation:  hybridTLS,
    match: func(name string) bool {
        return strings.Contains(name, "HYBRID") ||
            (containsAny(name, postQuantumHalves) && containsAny(name, classicalHalves))
    },
}

The spellings you meet in deployment are not only the one a given Go release implements. SecP256r1MLKEM768 and X25519Kyber768Draft00 are both live on the public internet. Recognising the shape (a post-quantum half plus a classical half) rather than enumerating constants is what keeps those from being reported as unknown. That matters more than it sounds, because unknown is not a harmless verdict: in the migration ordering it carries weight 2, on the reasoning that nobody has judged this one yet and an unjudged asset put last reads as a safe one.

What the answer changes downstream#

The probe's output is not a separate report. It merges into the inventory, and into the ordering the inventory implies:

bash
cbomscope cbom . -probe api.example.com:443 -o cbom.json

The ordering is score = posture × (exposure + lifetime), and two of those weights exist specifically for what a probe finds. A live endpoint scores 3 on exposure, because it is negotiated on a live endpoint: reachable by anyone who can connect, and recordable while it is. Key exchange scores 3 on lifetime, because it protects traffic that can be recorded today and decrypted once the assumption falls. A classical key exchange on a reachable endpoint lands at the top of the list by construction, and it only lands there if the probe offered a hybrid group and recorded the refusal. Without the offered list, the same row is unfalsifiable and the number in front of it is decoration.

The inverse case matters too. A hybrid result implies no work, so it goes into the report's settled set rather than getting dropped, because a report that omits what it checked reads exactly like one that never looked. For the same reason, hybrid and quantum_safe are not available as -fail-on rungs. A gate set to one of them would fail a build for having migrated.

What I would do differently#

Two things, and both are about the probe's configuration being data rather than context.

The first is that I would treat the address and the offered list together as the identity of a finding from the start, instead of treating the offer as a property of the run. One handshake from one place at one moment is a narrow measurement. An edge that serves different groups by region, or by TLS version, or on resumption, is not something a single connection can see, and a finding that records only the address invites being cached and re-read as a standing fact about the deployment.

The second is that certificate verification stays off, and I would resist every request to make it a flag. The job is to see what an endpoint presents, including an expired or self-signed certificate, and refusing to look would hide exactly the inventory that needs attention. A flag would turn that into a decision each user re-makes badly.

The general shape here is older than post-quantum cryptography. A negative result from an active measurement is a claim about two parties, and if you publish only one side of it, you have published a number whose meaning depends on information you threw away. Hybrid key exchange is probably just the case where that mistake is expensive, because the thing you get wrong is whether the traffic being recorded today is still readable in a decade.

react

$ new-project --brief

or email hey@polycratia.com