#!/usr/bin/env python3 """A cryptographic bill of materials, written as facts for the `pqc` module. A CBOM is a CycloneDX document, version 1.6 or later, whose cryptographic assets carry `cryptoProperties`. `read` turns one into a `Cbom`, and `programs` turns that into DKE Python programs, each sized for one run, through `dke.factwriter`. WHAT IS WRITTEN, by kind: - `Algorithm.`, for each cryptographic asset whose `assetType` is `algorithm`: `quantum`, "vulnerable" when its `nistQuantumSecurityLevel` is 0 (the document's own definition: none of NIST's categories is met) and "resistant" when it is 1 or more; and `strength`, its `classicalSecurityLevel` in bits. Each is written only when the document states it, so an algorithm with no quantum level is left unclassified and the module names every use of it in `unclassified()`. - `Use.`, for each `dependsOn` edge from a component that is not a cryptographic asset to an algorithm: `system`, the component, and `algorithm`, the algorithm. The subject is the two subjects joined by `__`. `provides` is not a use. CycloneDX says so in the field's own definition: a component that implements an algorithm does not imply the implementation is in use. An edge that reaches an algorithm only through another cryptographic asset, such as a protocol or a certificate, is not read either: the edge does not say which use of which algorithm it stands for. Both are counted, with edges to a reference the document does not define, in each program's header and in `Cbom.skipped`, so what was left out can be seen. Nothing a CBOM does not hold is written: what each use protects, how long that must stay protected, how long a system takes to migrate, the plan, the horizon and the deadlines are the customer's own facts, recorded beside these. SUBJECTS. Every subject is minted from the document's `bom-ref`s through `dke.factwriter.Ids`, in sorted order, so the same document gives the same subjects every time and loading it again revises the same cells. A use that a later document no longer lists is not removed by loading that document: forget it in the store. A store refuses a program larger than a size it is set up with, so the size to split at is yours to pass, as `FactLoad` describes. Usage: dke-pqc-cbom --max-bytes N [--source S] [--module M] print the programs, separated by a line holding only `# ---` """ from __future__ import annotations import argparse import json import pathlib import sys from dataclasses import dataclass, field from datetime import datetime from dke.factwriter import FactLoad, Ids MODULE = "pqc" SOURCE = "cbom" # cryptoProperties arrived in CycloneDX 1.6. FIRST_VERSION = (1, 6) @dataclass class Algorithm: subject: str quantum: str | None = None strength: int | None = None @dataclass class Use: subject: str system: str algorithm: str @dataclass class Cbom: """What a CBOM states that the module reads, with its subjects minted.""" serial: str stamp: int algorithms: list[Algorithm] = field(default_factory=list) uses: list[Use] = field(default_factory=list) # What was not read, and how many of each. skipped: dict[str, int] = field(default_factory=dict) def skip(self, why: str, n: int = 1) -> None: if n: self.skipped[why] = self.skipped.get(why, 0) + n def _components(doc: dict) -> list[dict]: """Every component the document defines: the BOM's own subject, then the component list, nested components in place.""" out = [] meta = doc.get("metadata", {}).get("component") if isinstance(meta, dict): out.append(meta) todo = list(doc.get("components", [])) while todo: c = todo.pop(0) out.append(c) todo[0:0] = c.get("components", []) return out def _version(doc: dict) -> tuple[int, int]: try: major, minor = str(doc.get("specVersion", "")).split(".")[:2] return int(major), int(minor) except ValueError: raise ValueError(f"specVersion {doc.get('specVersion')!r} is not a CycloneDX version") from None def _stamp(doc: dict) -> int: """An increasing number for the document: its timestamp in epoch seconds, or its revision number when it has none.""" ts = doc.get("metadata", {}).get("timestamp") if ts: return int(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()) return int(doc.get("version", 1)) def _level(props: dict, key: str) -> int | None: v = props.get(key) if v is None: return None if isinstance(v, bool) or not isinstance(v, int) or v < 0: raise ValueError(f"{key} {v!r} is not a non-negative integer") return v def read(doc: dict) -> Cbom: if doc.get("bomFormat") != "CycloneDX": raise ValueError("not a CycloneDX document") if _version(doc) < FIRST_VERSION: raise ValueError(f"CycloneDX {doc.get('specVersion')} has no cryptographic assets; " "1.6 or later is needed") cbom = Cbom(serial=str(doc.get("serialNumber", "")), stamp=_stamp(doc)) by_ref: dict[str, dict] = {} for c in _components(doc): ref = c.get("bom-ref") if not ref: cbom.skip("a component with no bom-ref") continue if ref in by_ref: raise ValueError(f"bom-ref {ref!r} is defined twice") by_ref[ref] = c def is_asset(c: dict) -> bool: return c.get("type") == "cryptographic-asset" def is_algorithm(c: dict) -> bool: return is_asset(c) and c.get("cryptoProperties", {}).get("assetType") == "algorithm" ids = Ids() subject = {ref: ids.get(ref) for ref in sorted(by_ref)} for ref in sorted(by_ref): c = by_ref[ref] if not is_algorithm(c): continue props = c["cryptoProperties"].get("algorithmProperties", {}) level = _level(props, "nistQuantumSecurityLevel") a = Algorithm(subject[ref], strength=_level(props, "classicalSecurityLevel")) if level is not None: a.quantum = "vulnerable" if level == 0 else "resistant" cbom.algorithms.append(a) edges: set[tuple[str, str]] = set() for dep in doc.get("dependencies", []): ref = dep.get("ref") if ref not in by_ref: cbom.skip("a dependency entry for a reference the document does not define") continue cbom.skip("a provides edge, which is not a use", len(dep.get("provides", []))) for target in dep.get("dependsOn", []): if target not in by_ref: cbom.skip("an edge to a reference the document does not define") elif not is_asset(by_ref[target]): continue elif is_asset(by_ref[ref]): cbom.skip("an edge from a cryptographic asset") elif not is_algorithm(by_ref[target]): cbom.skip("an edge to a cryptographic asset that is not an algorithm") else: edges.add((ref, target)) uses = Ids() for ref, target in sorted(edges): cbom.uses.append(Use(uses.get(f"{subject[ref]}__{subject[target]}"), subject[ref], subject[target])) return cbom def programs(cbom: Cbom, *, max_bytes: int, module: str = MODULE, source: str = SOURCE) -> list[str]: """The programs that write `cbom`, in the order they run, into a store holding the module as `module`, each claim from `source` and none larger than `max_bytes`. Each carries the load marker `Load.pqc_cbom_`, so a store can tell a load that finished from one that stopped part-way.""" load = FactLoad(module, source, max_bytes=max_bytes) q = load.q load.comment(f"CBOM {cbom.serial or '(no serial number)'}: {len(cbom.algorithms)} algorithms, " f"{len(cbom.uses)} uses.") for why, n in sorted(cbom.skipped.items()): load.comment(f"Not read: {n} x {why}.") load.comment("Written by dke.pqc.cbom.") load.load_marker("pqc_cbom", cbom.stamp) # A table's name becomes a variable of the program, so none is named as a # field is. Every name here is `rows_…`, and no field of the module begins # that way. load.table("rows_quantum", [f"({q(a.subject)}, {q(a.quantum)})" for a in cbom.algorithms if a.quantum is not None], [load.put("Algorithm.s.quantum", "r[1]")]) load.table("rows_strength", [f"({q(a.subject)}, {a.strength})" for a in cbom.algorithms if a.strength is not None], [load.put("Algorithm.s.strength", "r[1]")]) load.table("rows_use", [f"({q(u.subject)}, {q(u.system)}, {q(u.algorithm)})" for u in cbom.uses], [load.put("Use.s.system", "r[1]"), load.put("Use.s.algorithm", "r[2]")]) return load.render() def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(prog="dke-pqc-cbom", description="A CBOM, written as facts for the pqc module.") p.add_argument("cbom", type=pathlib.Path) p.add_argument("--max-bytes", type=int, required=True, help="the largest program your store accepts, from its documentation or its operator") p.add_argument("--source", default=SOURCE, help="the source every claim carries") p.add_argument("--module", default=MODULE, help="the name the store holds the module under") a = p.parse_args(argv) cbom = read(json.loads(a.cbom.read_text(encoding="utf-8"))) print("\n# ---\n".join(programs(cbom, max_bytes=a.max_bytes, module=a.module, source=a.source))) return 0 if __name__ == "__main__": sys.exit(main())