Contract: Canonical SBOM Identifier (v1)
Status
- Status: PARTIALLY IMPLEMENTED (2026-02-19; reconciled 2026-05-30)
- Owners: Scanner Guild, Attestor Guild
- Consumers: Scanner, Attestor, VexLens, EvidenceLocker, Graph, Policy
- Implementation state (verified against
src/):- IMPLEMENTED:
canonical_idcomputation lives inStellaOps.Scanner.Emitand is surfaced onCycloneDxArtifact.CanonicalId(and optionallySpdxArtifact.CanonicalId). Seesrc/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/CycloneDxComposer.cs(CanonicalizJson+ComputeSha256) and.../SbomCompositionResult.cs. - NOT YET IMPLEMENTED: the
stella:canonical_idSBOM metadata property is not emitted (see “Relationship to Existing Identifiers” below), andcanonical_idis not bound into the DSSE attestation subject (see “DSSE Subject Binding” below). Both sections are flagged inline. - PARTIAL / NAME COLLISION: the Attestor ProofChain persistence layer ships a live
canonical_id-keyed materialized view (proofchain.artifact_canonical_records), but itscanonical_idcolumn is the SBOM document digest (sbom_entries.bom_digest), NOT the Scanner JCS hash, and the advertised{canonical_id}Evidence Thread route is not yet wired. See “Attestorcanonical_idprojection” below.
- IMPLEMENTED:
Purpose
Define a single, deterministic, cross-module identifier for any CycloneDX SBOM document. All StellaOps modules that reference SBOMs must use this identifier for cross-module joins, evidence threading, and verification.
Audience: Scanner, Attestor, VexLens, EvidenceLocker, Graph, and Policy engineers who emit or consume canonical_id. The Status block below is the canonical record of what is implemented today versus what is still roadmap — read it before relying on any cross-module join described here.
Definition
canonical_id := hex(SHA-256(JCS(sbom_json)))
Where:
sbom_jsonis the raw CycloneDX JSON document. The Scanner emits CycloneDX 1.7 (theCycloneDX.CoreBommodel is serialized as 1.6 and upgraded to 1.7 output viaCycloneDx17Extensions.UpgradeJsonTo17; seeCycloneDxComposer.BuildArtifact). The canonicalization step itself is format-agnostic and applies to any well-formed JSON document.JCSis JSON Canonicalization Scheme per RFC 8785SHA-256is the hash functionhexis lowercase hexadecimal encoding
NOTE (verified 2026-05-30): The stored
canonical_idvalue is lowercase hex with NOsha256:prefix.CycloneDxArtifact.CanonicalIdis produced byComputeSha256(Convert.ToHexString(hash).ToLowerInvariant()) and its own XML doc comment states “Format: lowercase hex (no "sha256:" prefix)”. Earlier drafts of this contract specified asha256:prefix; that prefix is NOT present in the implementation. Callers that need a prefixed/algorithm-tagged form must add"sha256:"themselves.
Canonicalization Rules (RFC 8785)
Object Key Ordering
All JSON object keys are sorted lexicographically using Unicode code point ordering (equivalent to StringComparer.Ordinal in .NET).
Number Serialization
- Integers: no leading zeros, no trailing zeros after decimal point
- Floating point: use shortest representation that round-trips exactly
- No scientific notation for integers
CAVEAT (verified 2026-05-30): The current implementation (
CycloneDxComposer.WriteElementSorted) re-emits non-object/array values withJsonElement.WriteTo(writer), which preserves the original textual number token from the parsed input rather than re-normalizing it to RFC 8785’s canonical number form. In practice the input is produced deterministically by the CycloneDX serializer, so this is stable in the emit path, but feeding the same numeric value with a different textual representation (e.g.1.0vs1, or differing precision) into a standalone canonicalizer would NOT necessarily collapse to the same bytes. Strict RFC 8785 number canonicalization is therefore only partially realized. This is a known divergence, not a guarantee.
String Serialization
- Use
\uXXXXescaping for control characters (U+0000 through U+001F) - Use minimal escaping (no unnecessary escape sequences)
- UTF-8 encoding
Whitespace
- No whitespace between tokens (no indentation, no trailing newlines)
Null Handling
- Null values are serialized as
null(not omitted) - Missing optional fields are omitted entirely
Array Element Order
- Array elements maintain their original order (arrays are NOT sorted)
- This is critical: CycloneDX component arrays preserve document order
Implementation Reference
.NET Implementation (authoritative)
The canonical id is computed inside the Scanner emit pipeline. The production logic is the private method CycloneDxComposer.CanonicalizJson(byte[]) in src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/CycloneDxComposer.cs, followed by ComputeSha256. Equivalent inline logic:
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
// Canonicalize: parse, sort object keys ordinally (recursively), no whitespace,
// UnsafeRelaxedJsonEscaping. (RFC 8785 principles; see CanonicalizJson/WriteElementSorted.)
static byte[] Canonicalize(byte[] jsonBytes)
{
using var doc = JsonDocument.Parse(jsonBytes);
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions
{
Indented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
WriteElementSorted(doc.RootElement, writer); // recursive ordinal key sort
writer.Flush();
return stream.ToArray();
}
byte[] sbomJsonBytes = ...; // raw CycloneDX 1.7 JSON
byte[] canonicalBytes = Canonicalize(sbomJsonBytes);
byte[] hash = SHA256.HashData(canonicalBytes);
string canonicalId = Convert.ToHexString(hash).ToLowerInvariant(); // NO "sha256:" prefix
CAUTION (verified 2026-05-30): An earlier draft pointed callers at
StellaOps.AuditPack.Services.CanonicalJson.Canonicalize. That type is declaredinternal static(src/__Libraries/StellaOps.AuditPack/Services/CanonicalJson.cs) and is not callable from outside the AuditPack assembly. The Scanner does NOT call it forcanonical_id; it uses its own privateCanonicalizJson. The publicStellaOps.Canonicalization.Json.CanonicalJsonSerializeris a typed-object serializer (camelCase POCO → JSON with digest) and is NOT a drop-in canonicalizer for arbitrary pre-serialized CycloneDX JSON, so it does not reproduce thiscanonical_ideither. There is currently no shared public helper that takes raw SBOM JSON bytes and returns thecanonical_id; the logic is duplicated inline in the Scanner emit composer.
CLI Implementation
# Conceptual: canonicalize per RFC 8785, then hash. The hex digest is the canonical_id
# (no "sha256:" prefix). No first-party StellaOps CLI command currently exposes this.
jcs_canonicalize ./bom.json | sha256sum | awk '{print $1}'
Relationship to Existing Identifiers
All fields below live on the CycloneDxArtifact record in src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/SbomCompositionResult.cs.
ContentHash / JsonSha256 (CycloneDxArtifact.ContentHash, CycloneDxArtifact.JsonSha256)
- Current:
sha256(raw_json_bytes)– lowercase hex hash of the serialized JSON, NOT canonical. In the current implementationContentHashandJsonSha256are assigned the same value (jsonHash) inCycloneDxComposer.BuildArtifact. - Relationship:
ContentHashis a serialization-specific hash that depends on whitespace, key ordering, and JSON serializer settings.canonical_id(CycloneDxArtifact.CanonicalId) is a content-specific hash that is stable across serializers. - Coexistence: Both identifiers are retained.
ContentHashis used for integrity verification of a specific serialized form.canonical_idis used for cross-module reference and evidence threading.
CanonicalId (CycloneDxArtifact.CanonicalId)
- Current:
sha256(JCS(json))as lowercase hex (no prefix).requiredonCycloneDxArtifact; optional (string?) onSpdxArtifact. - This is the value this contract defines. It is exposed as a struct field on the emitted artifact, NOT (yet) inside the SBOM JSON document — see the metadata-property note below.
stella:canonical_id / stella.contentHash (SBOM metadata properties)
- NOT IMPLEMENTED (verified 2026-05-30). No
stella:canonical_idorstella.contentHashmetadata property is emitted into the CycloneDX document.CycloneDxComposer.BuildMetadataemits onlystellaops:*-namespaced properties (e.g.stellaops:generator.name,stellaops:sbom.view,stellaops:image.digest). A code comment inBuildMetadataclaimscanonical_id“is emitted post-composition … injected via the composition pipeline”, but no such injection exists —CanonicalIdis set only on theCycloneDxArtifactrecord afterBuildMetadatareturns, and is never written back intometadata.properties. This subsection describes a planned/aspirational property; treat it as roadmap until the emit pipeline writes it.
CompositionRecipeSha256 (SbomCompositionResult.CompositionRecipeSha256)
- Purpose: Hash of the composition recipe (layer ordering and per-layer component sets), not the SBOM content itself. Computed by
ComputeSha256(BuildCompositionRecipeJson(...)); also surfaced asCycloneDxArtifact.MerkleRootand embedded in thecas://sbom/composition/{sha}.jsonURI. - Relationship: Independent. Composition recipe describes HOW the SBOM was built;
canonical_iddescribes WHAT was built.
Attestor canonical_id projection (Evidence Thread read model)
CAVEAT (verified 2026-05-30): A second, distinct identifier named
canonical_idexists in the Attestor persistence layer. Despite the shared name it is NOT the Scanner’s RFC 8785 JCS hash defined above. Do not treat the two as interchangeable until the binding gap below is closed.
The Attestor ProofChain persistence library ships a live, embedded migration that introduces a canonical_id-keyed read projection:
src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/003_add_artifact_canonical_record_view.sql(SprintSPRINT_20260219_009, CID-04 — the same sprint that introducedCanonicalIdin the Scanner composer, CID-02). The file is an active embedded resource (the.csprojglobsMigrations\**\*.sqland excludes onlyMigrations\_archived\**).- It creates the materialized view
proofchain.artifact_canonical_records, one row percanonical_id, joiningproofchain.sbom_entries+proofchain.dsse_envelopes+proofchain.rekor_entries, with a unique indexidx_acr_canonical_idand a PURL indexidx_acr_purl. - Source of the column (critical): the view defines
se.bom_digest AS canonical_idand'cyclonedx-jcs:1'::text AS format. The underlyingproofchain.sbom_entries.bom_digestcolumn is documented onSbomEntryEntity.BomDigestas the “SHA-256 hash of the parent SBOM document” — i.e. a hash of the SBOM bytes as stored, not the Scanner’s JCS-canonicalized hash. So the projection currently labels the SBOM document digest ascanonical_idand tags it with thecyclonedx-jcs:1format string, even though no JCS canonicalization is applied at this layer. - Wiring gap: the migration header comment advertises an
Evidence Thread API (GET /api/v1/evidence/thread/{canonical_id}). As of 2026-05-30 no repository code queriesproofchain.artifact_canonical_records, and the wired Evidence Thread HTTP endpoints route by{artifactDigest}(e.g.EvidenceThreadEndpointsmapGET /{artifactDigest},POST /{artifactDigest}/export), not by{canonical_id}. Treat the{canonical_id}route in the migration comment as roadmap, not a live endpoint. - Net effect for this contract: the cross-module join promised in the Purpose section is not yet wired end to end through this projection — the Scanner’s
CycloneDxArtifact.CanonicalId(JCS hash) is not what the Attestor view stores under thecanonical_idname today. Closing the gap requires either feeding the Scanner JCS hash intosbom_entries/the view, or renaming the projection column to reflect that it is a document digest.
DSSE Subject Binding
ROADMAP / NOT IMPLEMENTED AS WRITTEN (verified 2026-05-30 against
StellaOps.Attestor.Types). The currentStellaOps.SBOMAttestation@1statement does not placecanonical_idin the DSSE/in-totosubject. In the actual sample (src/Attestor/StellaOps.Attestor.Types/samples/sbom-attestation.v1.json) thesubject[]binds the artifact/image digest (subjectnameis the image reference,digest.sha256is the image digest), while the SBOM’s own digest is carried inside the predicate, not the subject.
Actual statement shape (in-toto Statement v1 + StellaOps.SBOMAttestation@1 predicate)
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "ghcr.io/stellaops/scanner@sha256:<image-digest>",
"digest": { "sha256": "<image-digest-hex>" }
}
],
"predicateType": "StellaOps.SBOMAttestation@1",
"predicateVersion": "1.0.0",
"predicate": {
"document": {
"bomRef": "urn:uuid:...",
"digest": { "sha256": "<sbom-document-digest-hex>" },
"name": "scanner-webservice",
"version": "..."
},
"sbomFormat": "CycloneDX",
"sbomSpecVersion": "1.6.0",
"summary": {
"componentCount": 143,
"dependencyEdges": 212,
"metadataDigest": { "sha256": "<...>" }
},
"packages": [ ... ],
"relationships": [ ... ]
}
}
Notes grounded in source:
- The generated schema
src/Attestor/StellaOps.Attestor.Types/schemas/stellaops-sbom-attestation.v1.schema.jsondefines the predicate object withschemaVersion(constStellaOps.SBOMAttestation@1),subjectDigest(pattern^sha256:[A-Fa-f0-9]{64}$),sbomFormat,sbomDigest(aDigestReference={algorithm, value}),componentCount, and optionalpackages. TheSbomFormatenum in that schema is["CycloneDX-1.6", "SBOM-3.0.0"](the runtime sample uses the looser string"CycloneDX"+ separatesbomSpecVersion). - All
subject.digest.sha256/sbomDigest.valuedigests are hex WITHOUT thesha256:prefix, following in-toto convention. ThesubjectDigestpredicate field, by contrast, is the prefixedsha256:<hex>form per its schema pattern.
If/when canonical_id is bound into attestations, this section must be updated to reflect the exact field that carries it (e.g. predicate.document.digest vs. a new subject entry); do not assume the placeholder shape above is normative.
Stability Guarantee
Given identical CycloneDX content (same components, same metadata, same vulnerabilities), canonical_id MUST produce the same value:
- Across different machines
- Across different .NET runtime versions
- Across serialization/deserialization round-trips
- Regardless of original JSON formatting (whitespace, key order)
This is the fundamental invariant that enables cross-module evidence joins.
Test Vectors
Vector 1: Minimal CycloneDX 1.7
Input:
{"bomFormat":"CycloneDX","specVersion":"1.7","version":1,"components":[]}
Expected canonical form: {"bomFormat":"CycloneDX","components":[],"specVersion":"1.7","version":1} Expected canonical_id: compute SHA-256 of the canonical form bytes; encode as lowercase hex (no sha256: prefix).
Vector 2: Key ordering
Input (keys out of order):
{"specVersion":"1.7","bomFormat":"CycloneDX","version":1}
Expected canonical form: {"bomFormat":"CycloneDX","specVersion":"1.7","version":1} Must produce same canonical_id as any other key ordering of the same content.
Vector 3: Whitespace normalization
Input (pretty-printed):
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"version": 1
}
Must produce same canonical_id as the minified form.
Migration Notes
The bullets below are the intended migration plan; as of 2026-05-30 the first and last bullets are NOT yet implemented (see “DSSE Subject Binding” and the
stella:canonical_idmetadata-property note above). They describe target behaviour, not current behaviour.
- (Planned) New attestations should carry
canonical_idin a documented location once the binding lands. Today the SBOM attestation subject binds the artifact/image digest, and the SBOM document digest lives inpredicate.document.digest. - Existing attestations are NOT backfilled (they retain their original subject digests)
- Verification of historical attestations uses their original subject binding
- (Planned) The
stella:canonical_idmetadata property would be added to new SBOMs only — it is not currently emitted.
References
- RFC 8785: JSON Canonicalization Scheme (JCS)
- CycloneDX v1.7 specification
- DSSE v1.0 specification
- in-toto Statement v1 specification
- Source:
src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/CycloneDxComposer.cs(CanonicalizJson,WriteElementSorted,ComputeSha256,BuildMetadata,BuildArtifact) - Source:
src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/SbomCompositionResult.cs(CycloneDxArtifact.CanonicalId,SpdxArtifact.CanonicalId) - Source:
src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/CycloneDx17Extensions.cs(UpgradeJsonTo17, 1.6→1.7 spec-version rewrite) - Source:
src/Attestor/StellaOps.Attestor.Types/samples/sbom-attestation.v1.json,.../schemas/stellaops-sbom-attestation.v1.schema.json - Source:
src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/003_add_artifact_canonical_record_view.sql,.../Entities/SbomEntryEntity.cs
