Skip to content

CCoE conformance: OID identification, CBOM releases, attestation register and policy packs - #2

Open
rajnishprecogs wants to merge 32 commits into
mainfrom
feat/ccoe-conformance-attestation
Open

CCoE conformance: OID identification, CBOM releases, attestation register and policy packs#2
rajnishprecogs wants to merge 32 commits into
mainfrom
feat/ccoe-conformance-attestation

Conversation

@rajnishprecogs

Copy link
Copy Markdown
Contributor

Builds out the engine against the JLR CCoE cryptographic requirements register:
OID identification, CBOM versioning and signing, per-service conformance
profiles, an attestation register, and organisational policy packs.

What changed

Identification and schema

  • OID registry covering 59 algorithms, 9 curves and 17 composite signatures, with
    standardisation status carried alongside (GEN-004). OIDs are now emitted in
    CBOMs rather than resolved and discarded.
  • Version-parameterised CycloneDX emit and ingest, supporting 1.6 and 1.7 against
    the official schemas (GEN-001). A version with no bundled schema can be
    ingested but never emitted — we do not sign artefacts we cannot validate.

Release management

  • Four-state semantic diff keyed on stable component identity, semantic
    versioning, canonical-digest change log and stale-source handling (GEN-005).
    Reordered or re-serialised CBOMs produce no delta.
  • ML-DSA-65 change-log signing with hybrid classical+PQC support, where both
    halves must verify (GEN-006).

Conformance

  • 168-cell service/requirement grid across the seven CCoE services, with
    cross-service consistency checks.
  • Attestation register: 49 requirement schemas with validation, evidence
    integrity, review workflow and staleness, rendering to the native CycloneDX
    declarations object.
  • Per-component SSH cryptographic profiles covering GEN-009 clauses (a)–(f)
    from discovery and (g)–(h) from declaration.
  • CLM connectors (Venafi, Keyfactor, EJBCA) onto one normalised model.
  • HSM agent CBOM ingest, artefact identity, and HSM/PCAM profiles.

Policy packs

  • A customer's own cryptography standard, expressed as data: algorithm states
    and strengths, permitted cipher modes and RBGs, strength validity dates, and
    approval requirements. Findings then cite the customer's own clause rather
    than an external standard the reader has to map onto it.

Notes for review

  • No policy packs ship in this repository. Customer standards are normally
    confidential and are supplied by the deployment; the tests use an invented
    policy. This is deliberate, not an omission.
  • Behaviour change: a first CBOM release is now 1.0.0 under a new
    initial bump, not 0.1.0. An opening inventory is a baseline, not a
    pre-release, and an empty first CBOM already returned 1.0.0 — the two
    cases disagreed. Three tests added; none of the previous 237 covered it.
  • Correctness fix worth a look: an absent PKCS#11 attribute is now
    undetermined rather than false. Defaulting to false produced confident
    HIGH findings against correctly configured keys. Caught by running a real
    agent rather than a fixture.
  • Policy approval rules read tags from the policy's entry as well as the
    finding's. A scanner has no concept of "post-quantum", so a rule keyed only on
    finding-supplied tags would never fire and the requirement would silently
    never be raised.

Testing

264 tests, all passing (npm test). New suites cover the OID registry, service
profiles, attestation, SSH profiles, CBOM release and diff, PQC signing, spec
versions, CLM, HSM profiles and policy packs.

rajnishprecogs and others added 30 commits August 1, 2026 12:05
Foundations for the JLR CCoE PQC CBOM requirements register.

GEN-004 — authoritative algorithm identifiers. data/algorithm-oids.json
carries IANA/NIST CSOR/ANSI/IETF OIDs for PQC (FIPS 203/204/205), classical
and composite signature algorithms, plus curves and mode-specific AES.
src/utils/oid-registry.js resolves scanner-emitted names to them, flags
draft/provisional standardisation status as GEN-004 requires, and marks a
resolution approximate rather than guessing silently. PQC entries carry
"verify": true — cross-check against the live CSOR before issuing artefacts.

Service model — JLR assesses conformance per service, not per requirement:
18 cross-cutting requirements x 7 services + 42 service-specific = 168
assessable pairs, 124 of them Must Have. src/compliance/service-profiles.js
generates that grid, scores it, and ranks requirements by weighted cells
unlocked. Validated against JLR's own Service Matrix.

Cross-service consistency checks (XS-001..XS-010) reconcile a declaration
made under one service against evidence from another — an algorithm declared
"not implemented" but observed in use, a PQC implementation declared hardware
where evidence indicates a software stub, a CA key claiming HSM protection
with no resolvable artefact. Only computable where discovery and declaration
share one model, which is what CA-SVC asks for.

Attestation register — 25 requirements cannot be answered by scanning and 22
more need a declaration to complete a discovered fact. data/attestation-schemas.json
defines the field set per requirement so a declaration is structured and
checkable; src/attestation validates it, tracks staleness against the GEN-007
cadence, and renders into the native CycloneDX 1.6 `declarations` object
(assessors/attestations/claims/evidence) rather than a vendor extension.

Provenance and confidence are mandatory on every declaration — telling a
discovered fact from an asserted one is what the register is for.

27 tests added (92 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GEN-009 is a Must Have across all seven services and asks for eight things
per infrastructure component: (a) host key algorithms with sizes, (b) permitted
user auth types, (c) key exchange including PQC and hybrid, (d) ciphers,
(e) MACs, (f) implementation and version, (g) PQC KEX status, (h) host key
storage with an HSM cross-reference.

(a)-(f) come from discovery — live SSH_MSG_KEXINIT enumeration and sshd_config
parsing were already there but produced findings, not a structured declaration.
(g) and (h) cannot be observed and come from the GEN-009 attestation schema.

Two JLR constraints drive the design. "SSH host keys must be declared per
infrastructure component, a single blanket declaration is not acceptable" —
so buildSshProfile throws without a component, and summarise() reports
per-component rather than collapsing to one answer. "SSH host keys that are
software-stored rather than HSM-resident must be flagged as a risk item" —
so software-stored storage always produces a HIGH risk finding.

Deliberate restraint on what is not knowable: an RSA host key algorithm name
does not encode modulus size, so keyBits is null and keyBitsDetermined false
rather than a guessed 2048. An unparseable banner is undetermined, not
assumed. Undeclared and unobserved (g)/(h) stay undetermined.

PQC key exchange is recognised for ML-KEM hybrids (OpenSSH 10),
sntrup761x25519 (OpenSSH 8.5+) and the OQS/Amazon Kyber variants, and the
profile distinguishes ratified ML-KEM from non-standardised NTRU Prime —
PQC-002 requires that distinction. Observed PQC outranks a declaration and
records the contradiction when a supplier declared it unavailable.

CA-004 deprecation flagging covers all four algorithm classes and names the
standards that deprecate each, including the three JLR calls out explicitly:
ssh-rsa with SHA-1, diffie-hellman-group1-sha1 and 3des-cbc.

network-scanner now returns the negotiated cipher and MAC name-lists; they
were computed for weak-algorithm findings and then discarded.

26 tests added (118 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GEN-005 requires each CBOM release to carry a semantic version and a signed
change log of additions, removals and modifications. Cited by UNECE R155
Annex 5 and ISO/SAE 21434 Clause 12; JLR's rationale is "required for UNECE
R155 audit evidence" — the change log is what a type-approval auditor reads.

Kept separate from output/diff.js, which is a CI regression gate answering a
different question (does this scan introduce weak crypto?).

Two correctness properties drive the design.

Identity is semantic, not textual. The engine mints a fresh uuid for any
component whose finding carries no stable id, so bom-ref alone is not a key —
a re-scan would read as "everything removed and recreated". bom-ref is used
only when unique on both sides; everything else pairs on a content identity of
assetType + name + location (+ subject/issuer for certificates, type for key
material and protocols). Mutable attributes are deliberately excluded: an
initial version hashed parameterSetIdentifier and mode into the identity,
which made every genuine update read as a removal plus a creation. Tests for
re-ordering, uuid churn, line movement and path-separator differences all
assert zero delta.

An unreachable source is not a removal. Components from a source a scanner
could not reach are `stale` and prior state is retained. Recording them as
removed would let an outage silently delete assets from JLR's register.

Versioning is stated so an auditor can check it: major for a removal or
weakened posture (both change what the CBOM asserts about risk), minor for
new assets, patch for metadata, and no bump at all when nothing changed —
an unchanged estate should not manufacture releases.

The change log carries a canonical digest computed over a key-sorted
serialisation excluding the signature slot, so signing is deterministic and
tampering is detectable. The signer is injected rather than hardcoded, and a
classical signature is explicitly marked gen006Compliant:false with a note —
GEN-006 requires ML-DSA-65 at minimum and a classical signature must never be
silently presented as satisfying it.

27 tests added (145 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLM-SVC was the only one of JLR's seven services with no discovery path at
all — five service-specific requirements plus eighteen cross-cutting ones
resting on nothing. It is also the incumbent competitor's home ground, which
made a visible blank there the most expensive one on the board.

Adapters for Venafi TLS Protect (TPP), Keyfactor Command and EJBCA/PrimeKey.
EJBCA is worth first-class support for an automotive customer: it is widely
deployed for V2X and vehicle PKI, which is the on-board half of the estate.

Every adapter maps its own API shape onto one normalised inventory, and the
requirement profiles read only the normalised form. CLM-001, CLM-003 and
CLM-004 are therefore answered identically whatever the platform — the
consistency that the cross-cutting requirements are scored on. Adding a
fourth platform is one adapter and nothing else.

The map* functions are pure, taking a parsed API response, so the field
mapping — where the bugs actually are — is tested without a live platform.
Field names follow each vendor's documented shapes and carry an explicit
warning to verify against the deployed version during onboarding: a silently
mis-mapped field yields a confidently wrong CBOM, which is worse than an
absent one.

Deliberate restraint on what is not known. A missing certificate count is
`countDetermined: false`, never zero — zero would understate migration scope,
and CLM-001 exists to communicate that scope. Partial counts are reported as
incomplete rather than presented as a total. CLM-003 is never complete from
discovery alone, because JLR requires the PQC revocation size impact for
constrained V2X and embedded environments to be declared.

CLM-004 resolves allow and block lists to algorithm OIDs as JLR requires
rather than leaving them as names, reports anything unresolvable, and
implements XS-005: an algorithm both permitted by policy and deprecated under
CNSA 2.0 / SP 800-131A Rev 2 / BSI TR-02102 is a governance contradiction,
not merely a weak setting.

Metadata only throughout — no connector requests private key material or has
the capability to.

28 tests added (173 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consumes the CycloneDX 1.6 output of the on-premise PKCS#11 agent and turns
one scan into five requirement profiles: HSM-001 (PQC support), HSM-004 (key
agreement and HNDL), PCAM-001 (physical inventory), PQC-007 (hardware PQC
readiness) and KMS-001 (managed key types).

The more important output is the **artefact identity**. PKI-005, CS-003,
KMS-003 and KMS-007 each require a CA key, signing key or key tier to
cross-reference "the HSM CBOM artefact", and XS-001/002/003 fire when that
pointer does not resolve. The agent mints a fresh CBOM serialNumber and fresh
bom-refs on every scan, so neither can be the reference — the id is derived
from the device serial number, falling back to the token label. A test asserts
XS-001 resolves cleanly against a real artefact id and still fires against a
missing one.

Two bugs the tests caught. A naive trailing-digits split parsed ML-DSA-65 as
"ML-DSA with a 65-bit key"; PQC names carry their parameter set as part of the
identifier, as do hash and MAC names, so both families are now kept whole.
An HSM with neither serial nor label has no stable identity at all, which is
now a HIGH finding rather than a silent unresolvable reference.

Restraint where PKCS#11 cannot answer. Hardware-vs-firmware execution is not
observable through the mechanism list, so HSM-001 reports `undetermined` until
attested. KMS-001 states that supported-but-unused key types need the
mechanism list rather than presenting provisioned types as the whole
inventory. PCAM-001 reports make, model and firmware as missing rather than
guessing.

Material findings beyond conformance: a private key marked CKA_EXTRACTABLE
undermines the HSM protection claim at PKI-005 and CS-003 regardless of
algorithm strength, and AES-128 is flagged as non-compliant for new
provisioning under CNSA 2.0.

Known gap, tracked separately: the Java agent does not yet call C_GetTokenInfo,
so manufacturer, model, firmware version and serial number are unavailable
from discovery and must be attested. That is a small agent change and would
complete PCAM-001 from discovery alone.

23 tests added (196 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…false

Running the real Java agent output through the ingest — rather than a
hand-written fixture — exposed a contract gap. The agent emits no per-key
PKCS#11 attributes at all, so `hsm:sensitive` and `hsm:extractable` were
absent, defaulted to false, and every correctly configured private key raised
a false HIGH finding.

Absent is not false. Both attributes are now tri-state, findings fire only on
a reported value, and an agent that does not report them raises one INFO
noting the HSM protection claim at PKI-005 and CS-003 is unevidenced — a
coverage gap, not a compliance failure.

The fixture is now genuine CbomBuilder output rather than an approximation of
it, which is what made the difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GEN-006 requires the CBOM artefact to be signed with a PQC-capable scheme,
ML-DSA-65 at minimum, and permits hybrid during transition. It is a Must Have
across all seven services — 14 weighted cells, the largest single item in the
register. JLR's rationale: "an unsigned CBOM cannot be trusted."

Until now signing used RSASSA-PKCS1-v1_5-SHA256. Signing a post-quantum
inventory with a Shor-vulnerable signature is the kind of thing an evaluator
notices; output/sign.js is retained for the existing CLI path but is not
GEN-006 conformant and the change log already said so explicitly.

Implementation notes worth knowing:

@noble/post-quantum 0.6.1 is ESM-only and this engine is CommonJS, so the
module loads through a cached dynamic import. Signer *construction* is
therefore async while sign() and verify() stay synchronous, which keeps
cbom-release.signChangeLog(log, signer) synchronous as already written and
tested. The 0.6 API is sign(msg, secretKey) — the argument order changed from
earlier releases, and getting it wrong fails loudly rather than silently.

Pure JavaScript, no native addon, so the pkg binaries still build — that is
what answers CA-005: hand JLR's auditor a binary and let them verify our CBOM
themselves rather than trusting the SaaS.

The GEN-006 floor is enforced rather than documented: ML-DSA-44 is refused by
both generateKeyPair and createSigner, and a key whose length does not match
its declared parameter set is rejected before it can produce a signature that
would never verify. Tests assert the FIPS 204 sizes directly — 1952 / 4032 /
3309 for ML-DSA-65.

Hybrid signs the same digest with both components and requires both to verify.
A hybrid that passes on one component alone offers no more assurance than that
component by itself, and there is a test forging exactly that case. The
composition is declared as concatenation with standardisationStatus
'non-standardised' — PQC-002 forbids presenting a non-standardised
construction as settled, and explicitly rejects vague claims of hybrid support.

Signatures are over a canonical key-sorted digest, so reordering a document
cannot invalidate a signature and altering it always does. Both directions are
tested.

17 tests added (215 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for CycloneDX 1.7. `specVersion` was a hardcoded '1.6' in the CBOM
and QBOM generators and the validator loaded one schema by filename, so
supporting another version meant editing three places and hoping nothing else
had its own copy.

GEN-001 imposes two different obligations, and conflating them is how a CBOM
tool ends up emitting something it cannot validate:

  EMIT   — only versions whose official schema is bundled.
  INGEST — 1.4 upward, because GEN-001 exists to stop legacy SBOM data being
           lost, and our RFI response commits to ingesting 1.4 and 1.5.

The load-bearing rule: a version with no bundled schema can be ingested but
never emitted. Emitting a document labelled 1.7 while holding no 1.7 schema
would assert a conformance nobody has checked, which is worse than not
offering the version at all. `resolveEmitVersion('1.7')` therefore throws
today, and will start working the moment schemas/bom-1.7.schema.json exists —
no code change.

Validation is now per declared version rather than always-1.6, and reports
`validated: false` for a version whose schema is absent. Unvalidated and
invalid are different claims: conflating them either overstates conformance or
rejects a perfectly good document.

1.4 and 1.5 are refused for emit for a second, separate reason — they have no
cryptographic asset model at all. That is the substance of clarification C4 to
JLR: GEN-001 cites 1.4, but cryptoProperties, assetType and algorithmProperties
were upstreamed in 1.6.

A version newer than this build knows is still ingested, unvalidated, rather
than discarded.

Caught by the tests: the qbom generator took the new specVersion field but a
regex patch silently failed to add the resolver call, so it emitted
`specVersion: undefined` and four schema tests failed. Worth noting because
the failure mode — a document that looks fine until something validates it —
is exactly what this module exists to prevent.

15 tests added (230 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CycloneDX 1.7
Fetched the official bom-1.7 schema, plus cryptography-defs.schema.json — 1.7
splits algorithmFamiliesEnum and ellipticCurvesEnum into a side schema, and
without it the 1.7 validator will not compile at all. The validator now loads
whichever side schemas are present rather than a fixed list, since the set is
version-dependent.

What 1.7 actually adds to the crypto model, having diffed it rather than
trusting our own RFI copy:
  algorithmProperties           + algorithmFamily, ellipticCurve
  certificateProperties         + serialNumber, fingerprint, certificateState,
                                  creation/activation/revocation/destruction
                                  dates, certificateExtensions
  relatedCryptoMaterialProperties + fingerprint, relatedCryptographicAssets
  protocolProperties            + relatedCryptographicAssets

relatedCryptographicAssets is the one that matters here: it is a native way to
express the cross-asset references PKI-005, CS-003, KMS-003 and KMS-007
require, which we currently carry as vendor properties. Worth migrating onto
once 1.7 is the agreed target — noted, not done in this change.

1.7 is additive over 1.6, and a test asserts the same CBOM validates as both.

GEN-004 in output
The OID registry existed and was tested but nothing called it, so emitted CBOMs
carried no cryptoProperties.oid — the requirement was satisfied in the library
and not in the artefact. algorithmComponent now populates it, with
standardisation status as component properties since CycloneDX has no field
for it, and emits algorithmProperties.curve when resolution finds one.

Caught while wiring it: ECDSA-P256 resolved its algorithm OID but not its
curve, because the alias map held P-256 while the name splits to P256. Curve
lookup now normalises the hyphen-less and NIST-prefixed spellings, so P256,
NISTP256, nist-p-384, secp256r1 and prime256v1 all resolve. Scanners and
PKCS#11 emit all of these.

An unresolvable algorithm still gets no OID and is marked unresolved — a wrong
identifier is worse than an absent one, and GEN-004 is about authoritative
identification.

7 tests added (237 total, all passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first release fell through to the "created" branch of nextVersion, so an
opening inventory was versioned 0.1.0 and its reason described the assets as
having been "added" — added to nothing. An *empty* first CBOM meanwhile hit the
fallback and returned 1.0.0, so the two cases disagreed with each other.

A register's first issue is its baseline, not a pre-release. Both now return
1.0.0 under a new `initial` bump, and the second release bumps from there.

Three tests added: none of the existing 237 covered the first release, which is
how this survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The built-in frameworks say what the industry requires. A customer's own
cryptography standard is usually stricter and is the one their auditors test
against, so a finding citing an external standard makes the reader do the
mapping themselves.

A pack expresses that standard as data: algorithm states and strengths, which
cipher modes and RBGs are permitted, how long a strength stays valid, and what
requires named approval. Findings then cite the customer's own clause.

Packs are data rather than code for two reasons. A customer standard is
normally confidential, so it must be deployable without being published in this
repository — no pack ships here, and the tests use an invented policy. And a
standard is reissued on its own review cycle, so it must be replaceable without
a software release.

Two decisions worth stating:

- An algorithm the pack does not mention is `unknown`, never compliant.
  Silence in a policy is not permission, and an estate full of unrecognised
  algorithms is a finding about the policy rather than a clean result.
- Approval rules read tags from the *policy's* entry as well as the finding's.
  A scanner has no concept of "post-quantum"; keyed only on finding-supplied
  tags, a PQC approval rule would never fire and the requirement would silently
  never be raised. Caught by running a real pack rather than a fixture.

24 tests; 264 total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`evaluate` covers the industry frameworks; `evaluatePolicy` covers the
customer's own standard. Both reachable from one module so a caller does not
need to know the pack machinery exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… CycloneDX registry

Coverage of algorithm identification was 21 families, each hand-written. The
CycloneDX registry describes 96 families as data — names, variant patterns,
standards, curves — so it is vendored rather than transcribed, and compiled
rather than reimplemented.

Three layers, only the middle one hand-maintained:

  data/registry/       upstream, vendored, never edited by hand
  data/registry-bindings.json  ours: errata, slot vocabularies, spec mapping
  src/utils/crypto-registry.js the compiler both feed

Vendoring pins by sha256, not by the registry's own lastUpdated stamp: the
published copy (96 families) and master (98) carry an identical stamp with
different contents, so the stamp cannot identify what a CBOM was generated
against. The published copy is vendored because an attestation should cite a
release; `--source master` previews what is coming.

The variant patterns are a parser specification. One compiler serves every
family, giving family, primitive and parameter set without per-family code.
Two things the patterns do not supply on their own:

  Slots are unbounded, so `HMAC[-{hashAlgorithm}]` would match almost anything
  beginning HMAC-. Each slot is bound to a vocabulary, and the curve and hash
  vocabularies are derived from the registry itself, so they track upstream.

  Patterns overlap, so matches are ranked by how much of the name was matched
  literally rather than absorbed by a slot. Names that stay ambiguous —
  RSA-PKCS1-1.5 is both an encryption and a signature scheme — report their
  alternatives rather than picking a winner silently.

Matching runs strict then loose, and reports which applied: HMAC-SHA-256 is
the registry spelling, HMAC-SHA256 is what people write, and a caller should
be able to tell the two bases apart.

Errata correct two defects in the published copy without touching its bytes,
which must stay verifiable against the published sha256. BLS13-381 does not
exist (the curves are BLS12-381 and BLS12-377, both listed elsewhere in the
same file) and the GOST standard is 28147-89, not 38147. Both are already
fixed upstream; a test fails when an erratum stops applying, so corrections
cannot outlive the defect.

Registry primitives are mapped per spec version — 1.7 added key-wrap, 1.6 has
no equivalent, so it degrades to `other` rather than emitting a document that
fails schema validation.

96 of 96 families identify across a 406-name corpus expanded from the
registry's own patterns. Algorithm OIDs remain overlay work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registry says what an algorithm is called. It says nothing about whether
you should be using it. That assessment is the overlay — the only one of the
three layers that is hand-maintained.

Keyed by registry family identifier, which is what keeps it honest: a family
renamed upstream orphans its entry and the drift test fails, rather than the
family quietly losing its assessment while everything still appears to work.
The overlay also records the sha256 of the registry copy it was built against,
so the two cannot drift apart unnoticed.

Every one of the 96 families carries a status and a quantum exposure:

  broken 11   deprecated 7   legacy 14   active 63   test-only 1
  shor 21     grover 68      safe 5      depends 2

OIDs are resolved, not restated. The registry carries curve OIDs but no
algorithm OIDs, and sets additionalProperties:false on the family object, so
there is nowhere upstream to put one. Rather than open a second OID table to
contradict data/algorithm-oids.json, each family declares how to resolve
against the existing verified set: statically for a family with one OID, from
the parameter set where the OID varies (AES by size and mode, ML-KEM by
parameter set). 26 families resolve an OID today. The rest report the absence
with a reason — an OID no authority has assigned cannot be cited, and the
family identifier is the identifier of record in that case.

This carries the RSA split. The registry has no RSA family; it has four, and
the padding distinction the old single "RSA" entry collapsed is exactly the
one that decides whether a finding is raised. PKCS#1 v1.5 encryption is
deprecated (Bleichenbacher, ROBOT); the v1.5 signature scheme is merely
legacy; OAEP and PSS are active.

Selector semantics live here too, not in the compiler. The registry states
that AES offers (128|192|256) — that the first choice means a key length is
judgement, so the compiler reports selectors positionally and the overlay
names them. A test asserts no family names more selectors than its patterns
can produce, which caught 17 families whose key length and mode are open
slots rather than enumerated choices.

Detection patterns are declared but not yet populated; families are identified
by name, not yet hunted for in source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registry was vendored complete but only half consumed: 246 curves arrived,
125 of them carrying an OID, and exactly one field was being read — the name,
to build the pattern-matching vocabulary. Curve OID lookups still ran off the
nine hand-written entries in data/algorithm-oids.json, so a certificate on
brainpoolP512r1 or B-571 identified its algorithm and reported no curve.

Curve resolution now runs off the vendored data. 125 of 125 assigned OIDs
resolve, up from 16. The curve form comes with them — Montgomery, Edwards,
TwistedEdwards or Weierstrass — which is what separates a curve used for key
agreement from one used for signatures.

All nine hand-written OIDs agree with the registry, so nothing needed
overriding. A test asserts that agreement rather than leaving two sources of
truth to diverge quietly.

Two properties of the upstream data shaped the design:

  A curve registered by several bodies has several OIDs, all correct. P-224 is
  1.3.132.0.33 under SECG and 2.23.43.1.4.12 under the WTLS arc. Precedence in
  the bindings decides which is reported, ranked by which identifier actually
  turns up in a certificate; the others stay on the result, since a certificate
  in the field may carry either.

  Aliases are cross-references between full entries, not shorthand for entries
  that do not exist — every alias but one is also a curve in its own right, in
  the group that named it. Resolution therefore has to agree on the OID
  whichever name is presented, and preserves the name it was given so a report
  can echo what the source actually said.

Spellings the registry does not enumerate are normalised: P256, NIST P-256 and
nistp256 all reach 1.2.840.10045.3.1.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…try table

Detection and identification were conflated. The scanners find algorithm names
in source competently — a known API call, a constant, a config value — but then
resolved those names against a curated table of about fifty entries. Anything
outside it fell through to a default of LOW risk with pqcVulnerable false.

That default was the bug. ECIES, EC-ElGamal, SRP, ECMQV, J-PAKE, SM2 and BLS
are all broken outright by Shor, and every one of them was reported as low-risk
and quantum-safe — a false negative in the one claim the product exists to make.

Names now resolve against the registry when the curated table has no entry, so
the table's hand-tuned scores and notes still win where it has an opinion.
Coverage of registry-generated names goes from 61 of 378 to 376. The two
remaining are A5/1 and A5/2, lost before the registry sees them because the
scanner's normaliser splits on "/" to handle Java transform strings such as
AES/GCM/NoPadding; fixing that is a separate change with its own regression
risk.

Findings now record how the name was obtained, on an ordered ladder adapted
from the identity priority ladder in the Enterprise CBOM Aggregation Engine
design. That ladder ranks evidence for two components being the same asset;
this one ranks evidence for a string being an algorithm. Same principle: first
tier that applies wins, and weak tiers carry a confidence score and a review
flag rather than being discarded.

  1 api-call-exact       1.00  registry spelling at a known call site
  2 api-call-normalised  0.90  real-world spelling at a known call site
  3 api-constant         0.85
  4 config-value         0.75
  5 import-inferred      0.60  available, not necessarily used
  6 string-literal       0.50  review
  7 identifier-name      0.30  review — a name can outlive its algorithm
  8 ambiguous            0.25  review — more than one family accepts it

Quantum exposure now raises the risk floor: an active but Shor-vulnerable
algorithm grades MEDIUM rather than SAFE, because harvest-now-decrypt-later
makes it a scheduled problem rather than a solved one.

Registry matching gains a third and last pass for names that differ only in
separator spelling — CTR-DRBG for the registry's CTR_DRBG. Which separator a
name uses carries no meaning and tooling rewrites them freely. The pass is
reported as `respelled`, never as an exact match.

Appendix A is now generated from the vendored registry rather than asserting a
count in prose, and cites the sha256 of the copy it was generated from. A test
fails when the committed copy goes stale.

  npm run appendix        regenerate
  npm run appendix:check  fail if stale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registry carries a standards reference with a DOI or publisher URL for
almost every family, and nothing read it. identify() has been returning the
citations since the compiler landed and they went straight in the bin.

They are what separates a claim from evidence. The appendix asserted that a
family is deprecated, or falls to Shor, on Precogs' say-so; an assessor's
reasonable next question is "says who?". Each family now carries the document
it is defined by, taken from the vendored registry and passed through
unmodified — so the answer is upstream attribution rather than our word.

Variant-level citations are included alongside family-level ones, because
several families define individual modes in separate documents: AES-GCM is
SP 800-38D, not FIPS 197, and AES alone cites fifteen standards across its
modes. Folding in the variant citations also recovered GOST, leaving A5/1 as
the only family with no upstream attribution at all — stated in the appendix
rather than left as an unexplained blank.

A new A.7 lists all 108 referenced standards with their URLs, and says plainly
that none of them is added, edited or supplied by us.

Tests assert every attributed family shows its attribution, every referenced
URL appears in A.7, and that A5/1 remains the single documented exception — so
a family losing its citation upstream fails the build rather than quietly
appearing unsourced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and fix the AES tiebreak

Three identification gaps, found by testing our coverage against the alias table
in the Unified-CBOM-Engine rather than assuming we would win.

Bare family names did not identify at all. ML-KEM, ML-DSA, SLH-DSA, XMSS,
Argon2, SHA-2 and RSASSA-PSS all mandate a parameter set in their patterns —
ML-KEM-(512|768|1024) admits no bare ML-KEM — but a CBOM component routinely
carries the family in one field and the parameter set in another, so ingesting a
vendor document lost them silently. A fourth and weakest match pass resolves a
name that is exactly a family. It reports its own `family-name` mode and invents
no parameters, and grades at its own tier: the family is established, the
parameters that decide its strength are not, and AES-128 and AES-256 are not the
same finding.

Pre-standardisation names did not identify either. The registry knows ML-KEM;
code written between 2019 and 2024 says Kyber. Names are now rewritten before
matching, with parameter sets translated where the mapping is exact
(Dilithium3 -> ML-DSA-65). The alias is reported on the result rather than
swallowed, because the lineage is not equivalence — FIPS 203 ML-KEM differs from
round-3 Kyber in ways that matter for interoperability.

A bare AES resolved to primitive `key-wrap`. Every AES variant makes its
distinguishing group optional, so all four tie on literal weight, and the
tiebreak then compared *declared* slots — by which measure the key-wrap variant
looks tighter than the block cipher merely because it declares fewer. Compare
filled slots instead, since an optional the name never exercised cost the
pattern nothing, then prefer a family whose name is exactly the subject, then
fall back to registry declaration order.

That last ordering also settles ChaCha20, which both the ChaCha20 and ChaCha
families accept; the family actually called ChaCha20 now wins. It flips the
bare RSA-PKCS1-1.5 to the signature reading, which is neither better nor worse
— the name is irreducibly ambiguous and both readings are still reported — so
the round-trip test's exclusion set records the pair rather than a winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…omits

The registry carries ML-KEM, ML-DSA and SLH-DSA and nothing else from the
post-quantum field. Falcon, HQC, Classic McEliece, BIKE, FrodoKEM and NTRU are
all absent — verified against the vendored copy — so a scan of a PQC-experimental
codebase reported them as unidentified. That is a blind spot exactly where this
tool should be strongest, and it is the first real cost of the decision to vendor
upstream rather than hand-maintain.

The overlay could not absorb it: the overlay is keyed by registry family, so a
family upstream does not have has nowhere to live. data/registry-extensions.json
is therefore a fourth layer, and deliberately the smallest — six families, each
mirroring the registry's own shape so the same parser, compiler and vocabulary
machinery serves them with no special-casing.

Every extension is marked. The flag survives onto the identification result,
`families()` excludes them, and coverage counts registered families only, so
nothing that claims registry coverage can quietly include them. A test fails if
an extension ever collides with a registered family, which is what will happen
when upstream adds one — the signal to delete the extension rather than let both
survive a refresh.

Two of the six are not NIST selections and say so in their assessment: BIKE and
NTRU are marked legacy, sound but off the standardisation path. FrodoKEM stays
active because BSI and ANSSI recommend it precisely for avoiding the algebraic
structure ML-KEM depends on.

Bare `Falcon` also joins the pre-standardisation alias table, since FIPS 206 is
still in draft and both spellings are in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emit path predated the registry work and still scraped what it needed out of
the algorithm name. Two consequences, one of them a shipped defect:

parameterSetIdentifier was extracted with /-(\d{3,4})/, which on
RSA-PKCS1-1.5-SHA-256-2048 matched the *hash* and reported 256 as the key
length. It now comes from the registry's extracted parameters, so that name
emits 2048.

algorithmFamily was never emitted at all — grep found it only in a comment —
despite the registry computing exactly that. It is now emitted on 1.7, along
with ellipticCurve, and never on 1.6, where algorithmProperties forbids unknown
fields. Primitives map through primitiveForSpec, so key-wrap degrades to `other`
on 1.6 rather than producing a document that fails validation.

Three constraints in the 1.7 schema shaped this, all found by validating the
output rather than assuming it:

  Cipher mode is a closed lowercase enum. The registry knows XTS, CTS, OCB, SIV,
  KW and KWP; CycloneDX knows none of them, so anything outside the enum
  degrades to `other`. The precise mode survives on the component properties.

  ellipticCurve qualifies a curve by its standards group — brainpoolP512r1 is
  written brainpool/brainpoolP512r1. The registry carries the group, so the
  qualified form is constructed rather than guessed.

  algorithmFamily is a closed 93-entry enum, and the registry published
  alongside it defines 96. ANSI-KDF, TLS-PRF and SP800-56C are therefore
  families a conformant 1.7 document cannot name, as are the Precogs
  extensions. Those algorithms are still identified, still assessed and still
  emitted as components; the one constrained field is omitted rather than
  filled with a value that would fail validation.

Both enums are read from the schema this repo ships, so they cannot drift from
what actually validates.

Tests emit one component per registry family and validate the result against
both schemas, so a future version-gating mistake fails the build rather than
producing a document a customer's validator rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cribing it

The hand-written registry appendix was wrong in six places, and every one was a
transcription error rather than a mistake of understanding:

  BLS(13-381|13-377|BN254)     no BLS13 curve exists — our errata already fix it
  GOST38147                    the standard is GOST 28147-89 — likewise
  AES: four standards          the registry cites fifteen across its modes
  GOST: no standards           it cites RFC4357 through a variant
  Ed25519-ph, Ed448-ctx        the pattern is Ed(25519|448)[(ph|ctx)] — no hyphen
  CBOM example unversioned     algorithmFamily is a 1.7 field; 1.6 rejects it

The appendix was therefore less accurate than the engine it described, since the
engine corrects the first two at load time. Generating it from the vendored file
makes all six impossible: patterns come from the parsed registry with errata
applied, standards from the registry's own citations, worked examples from
expanding each pattern and confirming the name identifies back to the same
family, and the CBOM example from actually running the emit path.

Precogs extensions are listed separately and stated plainly as not being part of
the registry, so no count above them can be read as including them.

Two emit defects surfaced while generating the example, both pre-existing:

  classicalSecurityLevel parsed the key size out of the raw name, so
  RSA-PKCS1-1.5-SHA-256-2048 found none and fell to the 80-bit floor. It now
  takes the key length the registry extracted, and reports 112.

  cryptoFunctions came from the scanner's coarse category, so a signature scheme
  was described as encrypt/decrypt. It now follows the registry primitive. The
  old key-exchange branch also emitted `keyDerive`, which the CycloneDX enum
  rejects — it is lowercase `keyderive` — so any key-exchange finding produced a
  document that failed validation.

Tests pin each of the six defects and re-verify every name the appendix calls
valid by identifying it independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven defects in the certificate path, four found by reading it and three only
by running a scan against a live endpoint and looking at what came out. All
seven were silent: wrong or missing data in the artefact, no error anywhere.

The policy pack was assessing no certificates at all. `assess()` opens with
`const algorithm = f.algorithm || f.name; if (!algorithm) continue;` and a
certificate finding carried neither — it has `signatureAlgorithm` and
`publicKeyAlgorithm`. Nothing downstream supplied them: `aggregate()` does not,
and `applyRuleScore()` only reads `finding.algorithm`. So every certificate hit
the `continue` before classification, was not even recorded in `unknown[]`, and
a certificate estate assessed against JLR-STN-12418 returned `assessed: 0` with
no violations — which reads as a clean pass. This is the failure mode the
`available` flag was added to prevent, arriving through a different door.

EC certificates were never checked for key strength. The rule tested
`publicKeyAlgorithm === 'RSA'`, and `summarizeCert` left `publicKeySize` null
for EC, so RSA-1024 scored CRITICAL/92 while P-192 — weaker — scored LOW/10.
Curves are now mapped to equivalent security strength, because a curve's field
size is not its strength: comparing 256 against an RSA threshold would condemn
every P-256 certificate. New `publicKeyStrength` field, comparable across
families.

`pqcVulnerable` tested a hardcoded RSA/EC/DSA list, but `summarizeCert` could
only ever return RSA, EC or 'unknown' — forge does not structure DSA or
Ed25519 keys — so the DSA arm was dead and an unidentified key type was
silently treated as not quantum-vulnerable. The algorithm now falls back to the
SubjectPublicKeyInfo OID, and the test is a named set so an unrecognised family
is a visible omission rather than an implicit pass.

Certificates had no identity. `updateCorrelations` keys entirely on
`f.fingerprint` and no scanner has ever set one, so correlation was dead code:
the same certificate uploaded twice, found in a repository and served by an
endpoint produced three unrelated findings, and key reuse was undetectable.
Every certificate now carries SHA-256 over its DER, applied in all three places
certificates are produced so the identity is the same wherever it was found.

Found by running it, not by reading it:

- Node's peer-certificate object has no `sigalg` field, so
  `cs.sigAlg && /sha1|md5/` was always false and a network endpoint serving a
  SHA-1 certificate was never flagged. `cert.raw` is present, so the served
  certificate now goes through the same parser the file scanners use — one
  parser, one field set, and an endpoint's certificate is directly comparable
  with the same certificate on disk.
- The emitter derived the certificate's encoding with
  `path.extname(finding.file)`, and a network finding's `file` is `host:port`,
  so `precogs.ai:443` was written into the CBOM as encoding `ai:443`. A
  certificate inlined in a config file became `yaml`-encoded. Encoding now comes
  from the scanner's own `format`, is `der` for anything discovered over the
  wire, and falls back to the filename only when the extension is a real
  certificate encoding.
- `notValidBefore` was absent from every network certificate.

The fingerprint is emitted as CycloneDX `hashes` rather than a `cbom:` property
— it is the standard location, so a tool reading the BOM can match certificates
without knowing our conventions. Without this the identity existed only on the
internal finding and the exported artefact, which is what an auditor actually
receives, still could not be de-duplicated. Key size, curve, strength, expiry
and the self-signed and CA flags go in properties, because CycloneDX
`certificateProperties` has no field for any of them.

Scores change: EC certificates below 112-bit equivalent strength move to
CRITICAL, and endpoints serving SHA-1-signed certificates are flagged for the
first time. Both are corrections.

Two gap documents record what is still missing across the certificate and
network probes, including two header claims that overstate the code — there is
no STARTTLS handling despite the module claiming "STARTTLS awareness on 389",
and the certificate chain is fetched and discarded despite the header promising
it. Neither is fixed here.

18 tests; 282 total. Output validates against the official CycloneDX 1.6 schema
and emits identically at 1.6 and 1.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EqC9sbjYFQijhSebThTWu
…s variants as ambiguity

Both found by running the identification path against a representative finding
from each discovery surface rather than against names the registry generates.

Unqualified RSA key material did not identify at all. The registry models RSA by
scheme — RSAES-OAEP, RSAES-PKCS1, RSASSA-PKCS1, RSASSA-PSS — and carries no bare
RSA family, but a certificate, an HSM or a key scanner reports "RSA-2048" and
says nothing about padding, because padding is a property of how a key is used
rather than of the key. So the most common finding in any estate returned null,
and a null identification means no enrichment, which means `pqcVulnerable` stays
false. Every RSA key would have reported as quantum-safe.

RSA is therefore a registry extension, marked like the others. Where the scheme
IS stated the registry families still match first, so this only fires for the
unqualified name.

Separately, every AES finding was being demoted to the `ambiguous` review tier.
The ambiguity rule fired whenever a name matched more than one *variant*, but
every AES name satisfies the block-cipher, AEAD, key-wrap and MAC patterns at
once — that is one family described four ways, not a contest. Ambiguity now
requires two different families, so AES-256-GCM is exact and RSA-PKCS1-1.5,
which really is both an encryption and a signature scheme, stays ambiguous.

Tests assert one finding per discovery surface — certificate, key, endpoint,
vault, firmware, source — identifies with the right family and quantum posture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A missing intermediate is the most common TLS misconfiguration there is, and it
is invisible from the leaf alone — which was the only thing either probe looked
at. `getPeerCertificate(true)` has always returned the chain as a linked list
and `summarizePeerCert` has always read element zero and discarded the rest, so
an endpoint serving a leaf with no intermediate produced a clean certificate
finding and nothing else.

Walking that list needs care that a first attempt would not survive: Node
terminates the chain by pointing the root at *itself* rather than at null, so
the obvious loop never ends. `flattenPeerChain` stops on self-reference, on a
repeated fingerprint, and on a depth cap, in that order — the last two also
cover a server presenting a deliberately cyclic or pathological chain, which is
not hypothetical for a scanner pointed at an untrusted host.

Every certificate the endpoint presents is now its own finding carrying
`chainDepth`, with `chainLength` on the leaf. Intermediates are inventoried
rather than scored: an asset register that records only leaves is incomplete,
but an intermediate's weak key is the CA's problem, not this endpoint's, and
scoring it against the endpoint would misattribute it. Expiry is the exception,
because an expired intermediate breaks this endpoint today.

Chain completeness moved out of pki-scanner into utils/cert-chain, because it is
the same fault wherever the certificates came from — a keystore, a PEM bundle, a
PKCS#12 file or a handshake. The certificate probe now runs it on any upload
yielding more than one certificate, so uploading a leaf without its intermediate
is caught at the point of upload rather than only during a repository scan. One
certificate is deliberately not enough to trigger it: a single certificate is
not a set, nothing about a chain can be concluded from it, and flagging it would
fire on every single-cert upload.

This is completeness, not validation. No signature is verified and no trust
store is consulted, so the findings say "incomplete" and never "untrusted". An
untrusted root remains undetected and both gap documents say so.

Plain LDAP was reporting as a clean result. Port 389 is cleartext by default and
TLS arrives only via StartTLS, which this scanner does not implement, so the
direct handshake failed and produced the generic LOW/10 "no TLS handshake
succeeded" — indistinguishable from a host that simply is not there, for what
may be an unencrypted directory. It now yields an explicit cleartext finding at
MEDIUM/55 stating that StartTLS support is *undetermined*: the scanner cannot
negotiate it, so it cannot rule it out either.

The module header claimed both "STARTTLS awareness on 389" and that it captured
the certificate chain. Neither was true. The chain claim is now true; the
STARTTLS one is gone, and the header states what the probe does not do —
no cipher-suite enumeration, no host-key fingerprint, no trust validation. A
header that overstates the code is worse than no header, because a reader has
no way to tell which sentences to believe.

One item planned for this change is deliberately absent. The SSH host-key
fingerprint was scheduled as wiring, and it is not: the probe sends only the
version banner and reads the server's KEXINIT, while the host key arrives in the
KEX reply, which requires sending our own KEXINIT and completing a key exchange.
That is protocol implementation and belongs with the cipher-enumeration work.

12 tests; 294 total. Verified against a live endpoint: precogs.ai:443 presents
precogs.ai -> YR2 -> Root YR -> ISRG Root X1, complete, no gap reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EqC9sbjYFQijhSebThTWu
… handshake

Reporting the suite a default handshake settled on answers a question nobody
asked. A server that *prefers* AES-GCM but still *accepts* 3DES looks clean, and
"what will this endpoint agree to" is the question a TLS assessment exists to
answer.

Enumerating properly turned out to be blocked in a way that is worth recording,
because it dictates the whole shape of this change. `tls.connect` can only offer
what the local OpenSSL implements, and OpenSSL 3 removed RC4, single DES, 3DES,
EXPORT, NULL and every anonymous suite: `tls.getCiphers()` on this build returns
none of them. The library route therefore cannot ask about precisely the suites
a scanner most needs to ask about. A server accepting RC4 would have been
reported clean, and no amount of iterating over `tls.getCiphers()` would have
found it.

So scanners/tls-clienthello.js builds the ClientHello by hand. Only the
handshake's first flight is needed — offer a suite list, read the ServerHello,
close — so no keys are derived, no certificate is validated and no cryptography
of our own is implemented. The cost of that independence is that we now own the
byte layout, which is why the module documents what a ServerHello does and does
not prove: a selected suite means accepted, a handshake_failure alert means not
accepted, and anything else is `unknown` and stays `unknown` rather than being
folded into "not accepted" where it would read as a clean result.

Enumeration offers everything, notes what the server picks, removes it and
repeats — one handshake per accepted suite plus one, rather than one per
candidate. Re-offering the accepted list reversed answers a second question for
one extra connection: whether the server applies its own preference or takes the
client's. That distinction is the finding, not trivia. A server honouring client
order negotiates the weakest suite any client asks for, so its own ordering
protects nobody. With a single accepted suite, preference is reported as null —
undetermined, because one suite says nothing about ordering.

data/tls-cipher-suites.json catalogues 58 suites by IANA wire value. `fs` and
`aead` are separate flags because a CBC suite is neither broken nor good, and
those two are what separate a merely working configuration from a sound one.

STARTTLS follows, for SMTP, IMAP and POP3, on the enumeration path as well as
the handshake — each enumeration round is its own connection, so the upgrade is
re-driven every time. Two things this got wrong first:

- The protocol was identified from the port. These services routinely run
  somewhere other than 25 or 143, and keying on the well-known port reports
  those as having no TLS at all. It now reads the greeting.
- POP3 greets with "+OK", which is also its STLS success reply, so testing the
  success pattern against the greeting reported the upgrade as complete before
  STLS had been sent. The state machine always starts at `greeting` now, and
  the comment says why so it does not get "simplified" back.

LDAP's StartTLS is deliberately absent: it is a BER-encoded extended request
rather than a text command and does not fit this shape, so 389 stays reported as
a cleartext port with an undetermined upgrade path.

SSH server-to-client cipher and MAC lists are now scanned. The two directions
are configured independently, so a weak cipher on the return path alone was
invisible. One finding per algorithm listing every directive that offers it,
rather than one per direction — a symmetric configuration is the normal case and
would otherwise double every finding.

Also fixed: SNI was being set to an IP literal, which RFC 6066 forbids and Node
warns about.

Enumeration is on by default, because a cipher inventory that silently omits
what the server would agree to is the wrong default; `enumerateCiphers: false`
turns it off. Measured at 1.5s for a two-version endpoint accepting three suites
each.

Still absent, and stated in the header rather than left to be discovered: no
SSH host-key fingerprint (the key arrives in the KEX reply, which needs a full
key exchange), no revocation, no trust validation, and no SSLv2/SSLv3 — Node
cannot negotiate them.

18 tests; 312 total. Enumeration and STARTTLS are tested against servers started
inside the test process, so the suite stays offline and the accepted suites and
preference behaviour are known in advance rather than asserted loosely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EqC9sbjYFQijhSebThTWu
Checked against the KTSecure PKCS#11 agent's own CbomBuilder, which documents
its property names as "the contract the platform's HSM ingest reads".

Two things were wrong on this path.

PKCS#11 does not name algorithms the way the registry does. CKK_EC is reported
as "EC", which is no registry family — the registry separates ECDSA from ECDH
and PKCS#11 does not, because on a token that distinction is CKA_SIGN and
CKA_DERIVE rather than the key type. So every EC key in an HSM went
unidentified. The usage flags now decide it, and where the agent reported none,
ECDSA stands as the more common case and is marked approximate; both readings
fall to Shor and share the id-ecPublicKey OID, so the approximation costs a
label and nothing that affects a migration decision.

More seriously, the HSM path emitted only *posture* findings — an extractable
private key, an unreported CKA_SENSITIVE — which are statements about how the
device is configured. The keys themselves never appeared as cryptographic
assets: no inventory entry, no CBOM component, no quantum posture. An RSA-2048
signing key in a Luna is the same asset as an RSA-2048 key found in a vault and
now reads that way, through a new `assets` result shaped like every other
scanner's output so the platform can feed it through the same posture pipeline.

Keys now carry algorithmFamily, algorithmOid, algorithmStatus and curveOid —
secp256r1 resolves to 1.2.840.10045.3.1.7 — with the regex classifier kept as
the fallback for token types the registry does not recognise, so an unusual key
still gets a posture rather than silently reading as safe.

hsm:hardwareVersion was being dropped. C_GetTokenInfo reports hardware and
firmware versions separately and PCAM-001 asks about the physical asset: a
device can be re-flashed without the board changing. hsm:curve and
hsm:objectClass are now read as well, alongside the standard CycloneDX fields
that already carried them.

No agent code here, and none needed — this is the receiving half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the manual-submission half of FR2. A supplier sends the CBOM for the ECU
they ship, an air-gapped site hands over a file, another tool exports what it
found — all of it now lands in the same inventory as our own scans without ever
becoming indistinguishable from them.

Three rules, and they are the whole design.

Provenance is not optional. Every imported finding carries the sha256 of the
document, the tool that wrote it and when it arrived, and `detection: imported`
so a supplier's claim can never render as something we measured. Ids derive from
the document hash and bom-ref, so re-importing updates rather than duplicates.

Their names are re-identified, not trusted. A foreign CBOM says whatever its
tool prefers; running every name through the registry is what makes a supplier's
document comparable with our scans rather than merely adjacent to them. The
KTSecure PKCS#11 agent's EC keys are the interesting case — "EC" is no registry
family and only the usage flags say whether it signs or agrees — so the HSM
profile's resolver is reused rather than reimplemented.

Their grading is their policy and is not adopted.

Where the document asserts an OID and we resolve a different one, both are kept
and the disagreement is reported. Either their tool is wrong or we mean
different things by the same name, and neither is visible if one is silently
preferred.

Round-tripping our own emitter caught the design mistake worth recording: names
were being reassembled from algorithmFamily and parameterSetIdentifier, which
turns AES-256-GCM into AES-256 — and AES-256 resolves to the CBC OID, so the
importer reported a disagreement with a document we had just written. Candidate
names are now tried most-faithful-first, the whole component name ahead of any
reconstruction, and placeholder parameter sets like "default" are not
concatenated into nonsense.

What the document could not say is reported rather than defaulted: components
read, cryptographic assets found, how many carried no location, which names went
unidentified. CycloneDX 1.4 and 1.5 predate cryptoProperties entirely, so those
are accepted with the reason stated — importing an SBOM silently and reporting
zero findings would read as "this supplier uses no cryptography".

This does not merge. Correlating two CBOMs of the same asset is a different
problem with its own engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ick the version

FR3 asks for JSON and XML. Only JSON existed — there was no XML anywhere in the
engine — and the CLI could only emit 1.6, so "1.6 and 1.7" was reachable from
the library but not from the command line.

XML is a serialisation of the same document rather than a second generation
path, which is what makes the two equivalent in content rather than merely
similar. The JSON BOM stays the source of truth; XML is produced from it and
parsed back into it, so "no data is lost" is a claim that can be checked. It is:
a test round-trips a CBOM through JSON to XML to JSON on both 1.6 and 1.7 and
asserts the structures are identical.

Three CycloneDX XML rules the writer encodes, because each produces a document
that looks right and fails every validator:

  Some fields are attributes, not elements — type and bom-ref on a component,
  name on a property, serialNumber and version on the bom.

  Arrays are wrapped, and the singular is not always the plural minus an "s":
  dependencies wraps dependency.

  A property carries its value as element text, not as a value element.

An empty array emits an empty wrapper rather than being dropped, so it returns
as [] and not undefined. "No dependencies" and "we did not say" are different
statements and CycloneDX can express both.

Import accepts either format, detected from content rather than filename,
because an air-gapped transfer arrives named whatever somebody typed. The
provenance hash is taken over the bytes that arrived rather than a converted
form, so it stays checkable against the file the supplier actually sent.

The CLI gains --spec-version and --bom-encoding. Passing --spec-version 1.7 also
fixed a smaller inconsistency: the QBOM did accept a version and was never given
one, so a 1.7 CBOM was emitted beside a 1.6 QBOM — two documents about one scan
disagreeing about their own model.

xml2js was already present transitively and is now a declared dependency, since
the engine imports it directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rajnishprecogs and others added 2 commits August 14, 2026 17:25
Two claims in the FR3 response were not true. Both are now.

"CycloneDX is the native internal representation, not an export-time
conversion." The internal model is findings[] and CycloneDX is generated at
output, so as an architectural statement this was simply wrong — and it would
have stayed wrong short of a rewrite that made the working model worse. What the
claim is reaching for is that nothing is lost either way, and that is now true
and tested: a findings → CBOM → findings round trip dropped 9 of 18 fields, and
drops none.

The fields CycloneDX has no home for — the line column, the risk grade, the
description and remediation text, how confidently the algorithm was identified —
travel as precogs:finding:* properties and are restored on import. A foreign CBOM
carries none of them and is unaffected: it is still re-identified, still
re-graded, still marked as imported with an id derived from the document. The
distinction is exactly the one that matters — our own document coming home is
not a supplier making a claim.

Note detection is restored rather than overwritten. How an algorithm was found
is a fact about the scan that produced it and stays true after the document
travels; that this record arrived by import is recorded separately in
`imported`, so both are stated rather than one displacing the other.

"SPDX SBOM ingestion." Not implemented — the spdx.schema.json in the repo is
only the licence-expression enum CycloneDX $refs. Now implemented for SPDX 2.2,
2.3 and 3.0 JSON-LD.

SPDX has no cryptography profile in any version; 3.0.1's Security profile covers
vulnerabilities, not cryptographic assets. So an SPDX document cannot describe
an algorithm, a key or a certificate, and every import says so — not only when
the result is empty, because a document that silently yielded zero cryptographic
findings would read as "this supplier uses no cryptography". What it does yield
is real discovery: the packages, with the cryptographic implementations among
them flagged against the library table. Knowing an ECU ships OpenSSL 3.0.1 says
which implementation is present.

The tag-value serialisation is declined by name rather than mis-parsed as JSON.

src/ingest/index.js dispatches on content rather than filename, so one entry
point takes CycloneDX or SPDX, JSON or XML, and an air-gapped transfer named
whatever somebody typed still lands. supportedFormats() advertises what each
format can carry, with cryptographicAssets machine-readable rather than only in
prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to 41

The OID column sat at 26 of 96 and I had described that as close to the real
ceiling. Half of that was wrong. Of the 70 families without an identifier, 25
had one assigned by some authority and simply were not in our verified table —
a gap of ours rather than of the world's — and only 45 genuinely have none.

The table had been built around what a Western enterprise codebase typically
hits, and never extended to the national arcs. That matters for an automotive
supply chain: SM2, SM3 and SM4 are mandatory in the Chinese market, and ARIA and
SEED in the Korean one, so a JLR supplier scan would have reported the region's
standard cryptography with no authoritative identifier at all.

Every value here came from a primary specification and was corroborated against
a second source before being written down.

  ShangMi     GM/T 0002/0003/0004, corroborated via OpenSSL's object database:
              member-body(156) -> ISO-CN, 10197 -> oscca, 1 -> sm-scheme
  ARIA        RFC 5794 Appendix B, corroborated by OpenSSL
  SEED        RFC 4010, corroborated by OpenSSL
  PKCS#5      PBKDF2, PBES2 and PBMAC1 under 1.2.840.113549.1.5
  Legacy      CAST5, IDEA, RIPEMD-160, Whirlpool, Camellia-CBC, scrypt, HKDF

One correction worth recording for whoever reads RFC 5794 next: its ASN.1
module header says korea(400), which is a typo. The operative id-algorithm
definition three lines later says korea(410), which matches Korea's ISO
member-body code, SEED's arc and every implementation. The OIDs here use 410.

SM9 is deliberately left without one. GM/T 0044 assigns it under the OSCCA arc,
but it appears in neither an RFC nor the OpenSSL database, and an unverified
identifier in a compliance artefact is worse than an absent one — the family
identifier remains the identifier of record, which is what GEN-004 accepts.

The remaining 55 are mostly modern designs that ship as RFCs and IETF registries
rather than OID arcs: bcrypt, Argon2, BLAKE3, SPAKE2, X3DH, HPKE, ChaCha,
Poly1305 and the 3GPP set. For those the absence is the fact, not the gap.

A test pins 41 as a floor, so losing any of these fails the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants