polycratia

A cryptography inventory is a reconciliation problem

· 8 min read

Most post-quantum migration plans stall on the inventory, before the migration starts. Your source code says one thing about the cryptography you run, the TLS handshake says another, and until both sides are written down in the same format you are guessing about which one holds in production. You cannot migrate what you cannot see, and the algorithms that actually protect your traffic were picked by a handshake nobody in the building has read.

I have spent most of the last eight years on systems where money moves, so the shape of this problem is familiar. An internal ledger and a bank statement do not agree on the first pass. The rows that matched were the boring part: the work was in deciding what an unmatched row meant, and in refusing to auto-match anything just to make the report look clean. A cryptography bill of materials is the same exercise with different inputs: two sources, a disagreement, and a discipline about what you are allowed to infer.

That is the premise behind a small Go tool I maintain, cbomscope. It reads both sides and writes the result as a CycloneDX 1.6 cryptography bill of materials.

Two ledgers, and they disagree on purpose#

The first ledger is the code. Someone wrote a key size into a call years ago, and that line is a fact about intent at the moment it was written:

go
rsa.GenerateKey(rand.Reader, 2048)

Scanning for that is easy, and badly incomplete: almost nothing about the cryptography that protects a live connection gets decided in your repository. The second ledger is the endpoint itself:

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_vulnerable  ECDSA-SHA256            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

5 asset(s): quantum_vulnerable=2 quantum_reduced=1 hybrid=1 not_applicable=1

That hybrid key exchange group is the whole argument for the probe. No source file chose X25519MLKEM768. A runtime default, a terminating load balancer, a proxy in front of the service, or an operating system package upgrade chose it, and the repository is silent on all four. A scan of the code would have reported this deployment as less prepared than it is.

The reverse case is the one that costs you. An asset that shows up on the wire and nowhere in your code is a dependency on a decision made outside your change control. The day it moves (a base image bumps, a proxy config changes, a cipher is retired upstream) no diff in your repository will show it. In reconciliation terms that is an unmatched statement line: money arrived that your ledger does not explain, and the explanation lives in a system you do not own. You do not get to ignore it because your side balances.

What the scanner refuses to guess#

The temptation with a static scan is to grep. I did not, because grep produces the exact failure mode that makes reconciliation reports useless: confident rows that are wrong.

The scanner reads Go syntax trees. Import aliases are followed, so renaming a package at the import does not hide a call. A comment or a string literal that mentions rsa.GenerateKey is not a finding, because it is not a call. Parameters come from the call site where they are literal (the line above becomes RSA-2048), and where the key size arrives in a variable, the asset is reported with no size at all rather than a plausible default.

That last rule is the one I would defend hardest. AES-128 and AES-256 do not share a verdict: one is weakened by a quadratic speedup on unstructured search, the other is not meaningfully touched. Filling in a default so the row looks complete is the same move as auto-matching a payment to the nearest invoice because the amounts are close. It closes the ticket and leaves the exposure. An honest gap in an inventory can be assigned to a person. A wrong entry cannot, because nobody will look at it again.

The probe has a matching rule pointing the other way: it skips certificate verification on purpose. The job is to record what an endpoint presents, and an expired or self-signed certificate is exactly the inventory that needs attention, not an error that should abort the reading. The connection carries no application data.

Every verdict is a row, and every row cites a document#

What makes this usable by someone other than me is that the classification is not code. It is a table in internal/classify/table.go, and every row names the document it came from. RSA, DSA, DH, and everything elliptic-curve are quantum-vulnerable on Shor; AES and SHA-2 are judged by key and digest size; ML-KEM, ML-DSA and SLH-DSA are quantum-safe on FIPS 203, 204 and 205; MD5, SHA-1, RC4, 3DES and TLS 1.0/1.1 are broken already, on RFC 6151, RFC 7465, SP 800-131A and RFC 8996.

The citation travels with the finding, into the JSON output and into the CBOM as a cbomscope:citation property, so a reviewer can check a posture against the standard instead of trusting the tool. And because the verdicts are data rather than a chain of conditions, the tool can print what it believes without anyone reading its source:

console
$ cbomscope table
FAMILY          VERDICT             SOURCE
X25519MLKEM768  hybrid              draft-ietf-tls-hybrid-design: hybrid key exchange in TLS 1.3
ML-KEM          quantum_safe        NIST FIPS 203: ML-KEM, module-lattice key encapsulation
RSA             quantum_vulnerable  Shor 1997, SIAM J. Comput. 26(5): polynomial-time factoring and discrete logarithms; NIST IR 8547
AES             by size             Grover 1996, STOC: a quadratic speedup for unstructured search; NIST IR 8547
...

A family with no row comes out as unknown, with a rationale saying a person has to judge it and with no citation attached. That omission is deliberate. A source printed next to an answer nobody actually has would make the gap look reviewed, and that is worse than leaving it visibly open.

The gate has to be one you will still respect next quarter#

Only broken fails the command by default. quantum_vulnerable describes almost every deployment on earth right now, mine included: making it an error would turn the exit code into noise on the first run and probably get the tool pulled from CI inside a week. -fail-on is there for teams that have already done the work and want a tighter gate.

This is the same judgement as alerting on reconciliation breaks. If the alert fires on every unmatched line, it fires on all of them, and nobody reads any of them. You alert on the class you intend to act on this week, and you report the rest.

The artifact is what you are after:

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

What I would do differently#

I first treated this as an audit: run it, read the output, file the work. That was wrong, for the same reason a once-a-year reconciliation is wrong. The source-code side of the inventory only changes when someone commits, so a diff already covers it. The wire side changes without anyone committing anything, which means a CBOM generated once is a statement about a day that has already gone by. Generate it on a schedule against the real endpoints, commit the artifact, and diff it. The signal you want is the change in the file that no pull request explains.

The tool is narrow. Source scanning is Go only, covering the standard library's crypto packages, x/crypto/chacha20poly1305 and crypto/mlkem; the probe covers TLS version, cipher suite, key exchange group, and the certificate's key and signature. It needs Go 1.25 or newer, because reporting the negotiated key exchange group requires it, and it has no dependencies outside the standard library.

None of this predicts when a cryptographically relevant quantum computer arrives. It says what would fall if one did, from a table you can check, against two sources that are allowed to disagree.

react

$ new-project --brief

or email hey@polycratia.com