Contract: Canonical SBOM Identifier (v1)

Status

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:

NOTE (verified 2026-05-30): The stored canonical_id value is lowercase hex with NO sha256: prefix. CycloneDxArtifact.CanonicalId is produced by ComputeSha256 (Convert.ToHexString(hash).ToLowerInvariant()) and its own XML doc comment states “Format: lowercase hex (no "sha256:" prefix)”. Earlier drafts of this contract specified a sha256: 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

CAVEAT (verified 2026-05-30): The current implementation (CycloneDxComposer.WriteElementSorted) re-emits non-object/array values with JsonElement.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.0 vs 1, 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

Whitespace

Null Handling

Array Element 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 declared internal static(src/__Libraries/StellaOps.AuditPack/Services/CanonicalJson.cs) and is not callable from outside the AuditPack assembly. The Scanner does NOT call it for canonical_id; it uses its own private CanonicalizJson. The public StellaOps.Canonicalization.Json.CanonicalJsonSerializer is 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 this canonical_id either. There is currently no shared public helper that takes raw SBOM JSON bytes and returns the canonical_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)

CanonicalId (CycloneDxArtifact.CanonicalId)

stella:canonical_id / stella.contentHash (SBOM metadata properties)

CompositionRecipeSha256 (SbomCompositionResult.CompositionRecipeSha256)

Attestor canonical_id projection (Evidence Thread read model)

CAVEAT (verified 2026-05-30): A second, distinct identifier named canonical_id exists 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:

DSSE Subject Binding

ROADMAP / NOT IMPLEMENTED AS WRITTEN (verified 2026-05-30 against StellaOps.Attestor.Types). The current StellaOps.SBOMAttestation@1 statement does not place canonical_id in the DSSE/in-toto subject. In the actual sample (src/Attestor/StellaOps.Attestor.Types/samples/sbom-attestation.v1.json) the subject[] binds the artifact/image digest (subject name is the image reference, digest.sha256 is 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:

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:

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_id metadata-property note above). They describe target behaviour, not current behaviour.

References