polycratia

Cite the row, or say unknown: a checkable crypto classifier

· 9 min read

A cryptography inventory that prints RSA quantum_vulnerable is asking to be believed. Whether you can check it instead comes down to two design decisions that have nothing to do with scanning: where the published source of a verdict lives, and what the tool does with an algorithm name it has never seen.

I ran into both while building cbomscope (https://github.com/polycratia/cbomscope), which reads the cryptography out of a Go repository and off a live TLS handshake and writes the result as a CycloneDX 1.6 cryptography bill of materials. Scanning is the easy half. The hard half is that the document outlives the run.

A posture is a claim about cryptanalysis, not about the code

What an inventory asserts about an algorithm is not a property of the codebase. Shor's algorithm solves factoring and discrete logarithms in polynomial time, so RSA and ECDSA fall; Grover's quadratic speedup halves the effective strength of a symmetric key, so AES-128 becomes about 64 bits of quantum work. The RSA key in a test helper and the one signing production tokens carry the same posture. I separated those on purpose: which of the two you should fix first is a different question, and in cbomscope it belongs to plan rather than to the classifier.

It also means every posture in the output restates somebody's published result. Six months after the scan, the person reading the file is usually not the person who ran it. If the line says quantum_vulnerable and nothing else, they can trust the tool or redo the literature search. Both are bad outcomes for a document that is supposed to open a migration discussion.

So the verdict travels with its source.

The citation belongs in the row

Classification in cbomscope is a table. Each row is a family, a posture, a rationale you can read, and the citation the verdict was read from.

go
// Rule is one row of the classification table.
type Rule struct {
    // Family is matched as a prefix against the uppercased algorithm name. The
    // longest matching family wins, so SHA-1 is not read as SHA.
    Family string
    // Kind narrows the row: a protocol row is never applied to an algorithm,
    // nor the other way round.
    Kind asset.Kind
    // Posture is empty on rows whose verdict depends on a size.
    Posture   asset.Posture
    Rationale string
    Citation  string

    match func(name string) bool
    size  sizeRule
}

// PostureLabel is the verdict for display. A row settled by a size says so
// rather than showing an empty column.
func (r Rule) PostureLabel() string {
    if r.Posture == "" {
        return "by size"
    }
    return string(r.Posture)
}

The citations themselves are constants at the top of the file, which matters more than it sounds like it should. When they are string literals scattered through branches, nobody reads them as a set, and the same source gets paraphrased three different ways. As a block, they are an audit surface:

go
const (
    shor      = "Shor 1997, SIAM J. Comput. 26(5): polynomial-time factoring and discrete logarithms"
    grover    = "Grover 1996, STOC: a quadratic speedup for unstructured search"
    bht       = "Brassard, Hoyer and Tapp 1998, LATIN: quantum collision search in about 2^(n/3)"
    ir8547    = "NIST IR 8547: transition to post-quantum cryptography standards"
    fips203   = "NIST FIPS 203: ML-KEM, module-lattice key encapsulation"
    sp800131a = "NIST SP 800-131A Rev. 2: transitioning cryptographic algorithms and key lengths"
    rfc6151   = "RFC 6151: MD5 and HMAC-MD5 security considerations"
    shattered = "Stevens et al., CRYPTO 2017: the first SHA-1 collision"
    sweet32   = "Bhargavan and Leurent, CCS 2016: birthday attacks on 64-bit block ciphers"

    // What the attack costs is one half of the answer; what a migration is
    // expected to do about it is the other.
    shorMigration   = shor + "; " + ir8547
    groverMigration = grover + "; " + ir8547
)

That last pair is the part I would defend hardest. A citation that only names the attack explains why an algorithm is doomed and says nothing about what anyone expects you to do instead. Pair the result with the transition guidance and the row answers both halves of the question you actually have.

The table is data so the gaps stay visible

The other reason this is a table rather than a chain of conditions: a chain hides its own holes. A switch with no case for a family looks identical to a switch that was never asked about it. A list of rows can be counted and printed, and a missing row is a visible shape rather than an absent branch.

It also makes room for the families prefix matching cannot reach. A hybrid TLS group is spelled as a concatenation, and both of its halves would otherwise match rows of their own, so rows carrying a match function are tried before any prefix:

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

var Table = []Rule{
    {
        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))
        },
    },
    // ...
}

After the match rows, the longest matching family prefix wins, and that is the rule keeping SHA-1 from being read as SHA.

Print the table without running a scan

If the citations sit in the rows, the cheapest useful command in the whole tool is the one that prints the rows:

go
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "FAMILY\tVERDICT\tSOURCE")
for _, rule := range classify.Table {
    fmt.Fprintf(tw, "%s\t%s\t%s\n", rule.Family, rule.PostureLabel(), rule.Citation)
}

cbomscope table takes no arguments, reads nothing, and ends with the line n row(s). A family with no row here is reported as unknown. If you disagree with a verdict in a report you can go read where it came from without pointing the tool at anything, and -json emits the same rows through Rule.MarshalJSON for whoever wants to diff two versions of the table instead of two scans.

Unknown is an answer, and it has to cost something

Now the second decision. A classifier handed a name no row knows has three options: guess, pass it, or admit it. Guessing is wrong. Passing is the one that looks reasonable and isn't, because a clean report and an incomplete report become the same document.

In cbomscope, no row matching returns PostureUnknown with an empty Asset.Citation. The empty citation is load-bearing: a source printed beside an answer nobody has would make the gap look checked.

An unknown that merely sits in the output still loses, though, because it sorts somewhere near the bottom and nobody reads it. So the ranking treats it as work:

go
func postureFactor(p asset.Posture) (Factor, bool) {
    switch p {
    case asset.QuantumSafe, asset.Hybrid, asset.NotApplicable:
        return Factor{}, false
    case asset.Broken:
        return brokenNow, true
    case asset.QuantumVulnerable:
        return shorFalls, true
    case asset.QuantumReduced:
        return resizable, true
    }
    return unjudged, true
}

The false return is reserved for postures that genuinely imply no migration, and those assets are listed as settled rather than dropped, since a report that omits what it checked reads exactly like one that never looked. Everything else, including an asset that never reached the classifier at all, falls through to unjudged: ranked above the posture that is merely resizable, because an algorithm nobody has judged is a worse position to be in than a 256-bit hash with a known answer. Silence is not a clean bill of health.

The citation has to leave the tool

The last step makes the terminal output irrelevant. CycloneDX 1.6 has no field for a quantum posture, so writing one into a standard field would make the document lie to anything that reads it by the spec. Instead the posture, the rationale and the citation go out as namespaced properties beside the component:

go
type Property struct {
    Name  string `json:"name"`
    Value string `json:"value"`
}

cbom.Build writes cbomscope:citation next to the posture property. What you get is a file that can be attached to a release and read a year later by someone who has never run the tool, where every claim about an algorithm carries the paper or the standard it came from, and the entries nobody could judge say so in the same vocabulary.

What I would do differently

A citation is a string. That is enough for you to go find the document and not enough for anything automated to notice when the document has moved. NIST SP 800-131A Rev. 2 already encodes a revision in prose, which is a hint that the field wants structure: a document identifier, a revision, and optionally a URL, so a row whose source has been superseded can be found by a tool rather than by someone rereading the constants block.

The second thing I would look at again is PostureLabel returning by size. Honest in a terminal column, but MarshalJSON emits the same label, so a program consuming the JSON table sees by size in a field that otherwise holds postures. Display logic leaked into the data representation, and the fix is for the JSON to expose the size-dependence as its own field instead of a magic string.

Neither of those changes the shape of the design. The shape is that a verdict without a printable source is an opinion, and an inventory of opinions is what this kind of tool exists to replace.

react

$ new-project --brief

or email hey@polycratia.com