polycratia

A post-quantum migration plan should print its arithmetic

· 8 min read

A cryptographic inventory lists what you have. A migration plan claims what to do first, and that claim is an opinion wearing a number. If you cannot recompute the score sitting next to a line, you have two moves available: accept the ordering, or ignore the tool. In my experience you ignore the tool.

I hit this building cbomscope (https://github.com/polycratia/cbomscope), which reads Go source and a live TLS handshake and writes a CycloneDX cryptography bill of materials. The inventory part was uncontroversial. A posture says what a quantum computer would do to an algorithm, which is a property of the algorithm, and nobody argues with a list. The plan command was different: it ranks the same assets by the work they imply, and the first version printed a sorted list with a score column and nothing else. It fell over the first time someone disagreed with it. They could not tell me which part they disagreed with, only that an RSA call in a test helper should not be sitting above the signature algorithm on a public endpoint. That is not feedback about the result. It is feedback about a weight, and the output gave them no way to say so.

The constraint

A migration plan gets read by people who have the authority to reorder it and information the tool does not have. This service is being decommissioned next quarter. That endpoint carries data with a ten-year confidentiality requirement. This dependency is pinned because the upgrade breaks an API nobody here controls. None of that is visible to a program that parses syntax trees and completes handshakes.

So the goal is not to be right about the order. It is to be legible enough that you can overrule one factor without throwing away the whole ordering. A hidden score forces an all-or-nothing judgement on the tool's credibility. A printed one shrinks the argument into something much smaller and much more productive: you think exposure should outweigh lifetime here, and now the conversation is about a number instead of about trust.

Small scales, declared

The weights are not literals buried in a scoring function. Each one is a named Factor (a label, a weight, and a reason), and the scoring functions do nothing but choose a row.

go
// postureFactor reports the weight of a posture, and false where the posture
// implies no migration at all. Anything it does not recognise — including an
// asset that never reached the classifier — is ranked as unjudged rather than
// settled: silence is not a clean bill of health.
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
}

func exposureFactor(a asset.Asset) Factor {
    switch {
    case a.Location.Host != "":
        return atEndpoint
    case a.Module != "":
        return inDependency
    default:
        return firstParty
    }
}

func lifetimeFactor(a asset.Asset) Factor {
    switch a.Primitive {
    case asset.KEM, asset.KeyAgree, asset.PKE:
        return keyExchange
    case asset.Signature:
        return signature
    }
    return storedData
}

Three questions, three scales, and the scales are small on purpose. Key exchange weighs 3 and a signature weighs 1, because the two run on different clocks: what is recorded off the wire today can be decrypted years from now, while a signature is forged at the moment it is verified. That is a deadline rather than a leak, and a deadline you can still move. A one-to-three range is a thing you can hold an opinion about. A zero-to-a-hundred range is not. It only looks more precise, and the extra digits are where fabricated confidence hides.

Why posture multiplies and the rest adds

go
exposure, lifetime := exposureFactor(a), lifetimeFactor(a)
report.Items = append(report.Items, Item{
    Asset:    a,
    Score:    posture.Weight * (exposure.Weight + lifetime.Weight),
    Posture:  posture,
    Exposure: exposure,
    Lifetime: lifetime,
})

The shape of the expression is itself a claim, and it should be one you can defend out loud. Exposure and lifetime add because they are independent reasons to move sooner: being reachable from the internet and protecting long-lived secrets are separate problems, and an asset with one of them is still work. Posture multiplies because it scales both. A classically broken primitive makes every other property worse, and a sound one makes them moot. Addition there would let a pile of mild factors out-rank something already broken, which is the failure mode people notice and do not forgive.

The expression goes next to the line

This is the whole argument in one method:

go
// Arithmetic is the score written out, so the number in a column can be checked
// against the line that carries it.
func (i Item) Arithmetic() string {
    return fmt.Sprintf("%s (%d) × [%s (%d) + %s (%d)]",
        i.Posture.Name, i.Posture.Weight,
        i.Exposure.Name, i.Exposure.Weight,
        i.Lifetime.Name, i.Lifetime.Weight)
}

Every ranked line carries the expression that produced its number, in named terms rather than bare integers. You do not have to find the source, and you do not have to take my word for the ordering: you can see that this line scored where it did because it was found at an endpoint and because it is a key agreement, and you can decide that in your deployment the second term is wrong. The factors also survive the JSON output, along with the formula itself on the report, so a plan that travels into a ticket arrives with its reasoning attached instead of a column of integers somebody has to reverse-engineer later.

The same instinct runs one layer down. The classification table prints on its own, with the published source each verdict was read from, because a reviewer who wants to check a posture should not have to read the source to find out where the verdict came from. And a family missing from that listing is the honest shape of a gap.

Settled, not dropped

The first version filtered out the assets with nothing to do. That is the natural implementation and it is a quiet lie:

go
// Settled are the assets with no migration work: post-quantum, hybrid, or
// carrying no posture of their own. They are counted rather than dropped,
// because a report that omits what it checked reads exactly like one that
// never looked.
Settled []asset.Asset `json:"settled"`

A plan listing four items and a plan listing four items out of sixty are different documents, and the difference matters most to you when you are deciding whether to believe the first number. Post-quantum algorithms, hybrid key exchange, and protocol versions with no posture of their own all go into Settled with their locations intact. The hybrid line earns its place in particular: on a deployment that has already moved its key exchange, the fact that something was checked and found to need nothing is the single most useful sentence in the report.

The weight I expect to defend

Anything the classifier did not recognise, including an explicit unknown posture, ranks at 2, above the Grover-reduced assets at 1. People read that as a bug. It is the one weight I most want printed, because it is the one most worth arguing with: an asset nobody has judged is not a settled asset, and a ranking that sorts unknowns to the bottom turns a gap in the table into a clean bill of health. The same refusal shows up elsewhere in the tool. A key size of zero means not determined rather than small, the classical security level returns zero rather than invent a number, modulus sizes between the published pairs are left out rather than interpolated, and a citation stays empty on an unknown so that a printed source does not make a gap look checked.

If you decide unjudged should sit below resizable for your codebase, you are now making an informed local decision. That is a better outcome than agreement, I think, and it is only reachable because the number is shown in parts.

What I would do differently

The facts the tool cannot see still have nowhere to go. Today someone who knows a service is being decommissioned has to apply that knowledge in their head, after the sort. I would rather it entered as an exposure override on the input side, so the adjustment shows up inside the printed expression like every other factor. A plan corrected by a human should still be a plan whose arithmetic you can read, not one with a silent post-processing step bolted to the end.

I would also hold the line hard on scale size. Every request to make the ranking smarter arrives as a request for one more factor, and three factors with reasons beat seven with none. The moment the expression stops fitting beside the line it describes, it stops being checkable, and an unchecked score is back to being an opinion.

The product is the expression. The number is just what it evaluates to.

react

$ new-project --brief

or email hey@polycratia.com