CanonJson API Reference

Audience: .NET developers computing content-addressed identifiers, signing DSSE payloads, or verifying attestations against Stella Ops services. CanonJson is the shared canonicalization and hashing primitive behind proof bundles, score proofs, and the attestor proof chain.

Namespace: StellaOps.Canonical.Json
Assembly: StellaOps.Canonical.Json
Source: src/__Libraries/StellaOps.Canonical.Json/ (net10.0)

The package does not declare an explicit <Version> in its .csproj, so it inherits the build-default assembly version. Treat the version above as unspecified rather than pinned to 1.0.0.


Overview

The CanonJson static class provides RFC 8785-compliant JSON canonicalization and cryptographic hashing utilities for content-addressed identifiers. It ensures deterministic, reproducible JSON serialization across all environments.

CanonJson is implemented as a partial class split across several files (CanonJson.cs, CanonJson.Canonicalize.cs, CanonJson.CanonicalizeParsed.cs, CanonJson.Versioned.cs, CanonJson.Hashing.cs, CanonJson.Writer.cs, CanonJson.Options.cs).

Default serialization behavior. The object-typed overloads (Canonicalize<T>, Serialize<T>, Hash<T>, etc.) first serialize with System.Text.Json using these defaults: PropertyNamingPolicy = CamelCase, WriteIndented = false, and Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping. This means property names are camelCased before sorting. Pass a custom JsonSerializerOptions to the available overloads to change this. (Source: CanonJson.Options.cs.)


CanonVersion Class

Static class containing canonicalization version constants and utilities.

Constants

ConstantTypeValueDescription
V1string"stella:canon:v1"Version 1: RFC 8785 JSON canonicalization
VersionFieldNamestring"_canonVersion"Field name for version marker (underscore ensures first position)
CurrentstringV1Current default version for new hashes

Methods

IsVersioned

public static bool IsVersioned(ReadOnlySpan<byte> canonicalJson)

Detects if canonical JSON includes a version marker.

Parameters:

Returns: true if the JSON starts with {"_canonVersion":, false otherwise

Example:

var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
bool versioned = CanonVersion.IsVersioned(json);  // true

var legacy = """{"foo":"bar"}"""u8;
bool legacyVersioned = CanonVersion.IsVersioned(legacy);  // false

ExtractVersion

public static string? ExtractVersion(ReadOnlySpan<byte> canonicalJson)

Extracts the version string from versioned canonical JSON.

Parameters:

Returns: The version string (e.g., "stella:canon:v1") or null if not versioned

Example:

var json = """{"_canonVersion":"stella:canon:v1","foo":"bar"}"""u8;
string? version = CanonVersion.ExtractVersion(json);  // "stella:canon:v1"

CanonJson Class

Static class providing JSON canonicalization and hashing methods.

Serialization Methods

Serialize

public static string Serialize<T>(T obj)
public static string Serialize<T>(T obj, JsonSerializerOptions options)

Canonicalizes an object and returns the canonical JSON as a UTF-8 string (the string form of Canonicalize<T>). The optional options overload controls the initial serialization. (Source: CanonJson.cs.)

Returns: Canonical JSON string (no version marker).

Example:

var obj = new { z = 3, a = 1 };
string canonical = CanonJson.Serialize(obj);
// Result: {"a":1,"z":3}

Canonicalization Methods

Canonicalize

public static byte[] Canonicalize<T>(T obj)

Canonicalizes an object to RFC 8785 JSON without version marker (legacy format).

Parameters:

Returns: UTF-8 encoded canonical JSON bytes

Example:

var obj = new { z = 3, a = 1 };
byte[] canonical = CanonJson.Canonicalize(obj);
// Result: {"a":1,"z":3}

An overload Canonicalize<T>(T obj, JsonSerializerOptions options) is also available; it serializes with the supplied options instead of the defaults. (Source: CanonJson.Canonicalize.cs.)


CanonicalizeVersioned

public static byte[] CanonicalizeVersioned<T>(T obj, string version = CanonVersion.Current)
public static byte[] CanonicalizeVersioned<T>(T obj, JsonSerializerOptions options, string version = CanonVersion.Current)

Canonicalizes an object with a version marker for content-addressed hashing.

Parameters:

Returns: UTF-8 encoded canonical JSON bytes with version marker

Exceptions:

Validation is performed by ArgumentException.ThrowIfNullOrWhiteSpace(version), so whitespace-only versions are rejected as well as empty strings.

Behavior with non-object roots: When the root value is not a JSON object (e.g. a primitive, string, or array), the value is wrapped in an object: {"_canonVersion":"<version>","_value":<root>}. (Source: CanonJson.Versioned.cs, WriteElementVersioned.)

Example:

var obj = new { z = 3, a = 1 };
byte[] canonical = CanonJson.CanonicalizeVersioned(obj);
// Result: {"_canonVersion":"stella:canon:v1","a":1,"z":3}

// With explicit version
byte[] v2 = CanonJson.CanonicalizeVersioned(obj, "stella:canon:v2");
// Result: {"_canonVersion":"stella:canon:v2","a":1,"z":3}

CanonicalizeParsedJson

public static byte[] CanonicalizeParsedJson(ReadOnlySpan<byte> jsonBytes)
public static byte[] CanonicalizeParsedJson(ReadOnlySpan<byte> jsonBytes, JavaScriptEncoder encoder)

Canonicalizes already-serialized raw UTF-8 JSON bytes (no object serialization step). Use this when you have JSON on the wire rather than a CLR object. The encoder overload lets the caller control output escaping; it throws ArgumentNullException when encoder is null. (Source: CanonJson.CanonicalizeParsed.cs.)

Returns: UTF-8 encoded canonical JSON bytes (no version marker).


Hashing Methods

Sha256Hex / Sha256Prefixed

public static string Sha256Hex(ReadOnlySpan<byte> bytes)
public static string Sha256Prefixed(ReadOnlySpan<byte> bytes)

Low-level SHA-256 helpers over arbitrary bytes. Sha256Hex returns a 64-character lowercase hex string; Sha256Prefixed returns the same value with a sha256: prefix. The Hash*/Hash*Prefixed methods below are built on these. (Source: CanonJson.Hashing.cs.)


Hash

public static string Hash<T>(T obj)

Computes SHA-256 hash of canonical JSON (legacy format, no version marker).

Parameters:

Returns: Lowercase hex-encoded SHA-256 hash (64 characters)

Example:

var obj = new { foo = "bar" };
string hash = CanonJson.Hash(obj);
// Result: "7a38bf81f383f69433ad6e900d35b3e2385593f76a7b7ab5d4355b8ba41ee24b"

HashVersioned

public static string HashVersioned<T>(T obj, string version = CanonVersion.Current)

Computes SHA-256 hash of versioned canonical JSON.

Parameters:

Returns: Lowercase hex-encoded SHA-256 hash (64 characters)

Example:

var obj = new { foo = "bar" };
string hash = CanonJson.HashVersioned(obj);
// Different from legacy hash due to version marker

HashPrefixed

public static string HashPrefixed<T>(T obj)

Computes SHA-256 hash with sha256: prefix (legacy format).

Parameters:

Returns: Hash in format sha256:<64-hex-chars>

Example:

var obj = new { foo = "bar" };
string hash = CanonJson.HashPrefixed(obj);
// Result: "sha256:7a38bf81f383f69433ad6e900d35b3e2385593f76a7b7ab5d4355b8ba41ee24b"

HashVersionedPrefixed

public static string HashVersionedPrefixed<T>(T obj, string version = CanonVersion.Current)

Computes SHA-256 hash with sha256: prefix (versioned format).

Parameters:

Returns: Hash in format sha256:<64-hex-chars>

Example:

var obj = new { foo = "bar" };
string hash = CanonJson.HashVersionedPrefixed(obj);
// Result: "sha256:..." (different from HashPrefixed due to version marker)

ICanonicalizable Interface

Namespace: StellaOps.Canonical.Json (source: ICanonicalizable.cs)

Interface for types that opt in to their own canonical JSON serialization and content-addressed digest.

Methods

string GetCanonicalJson();   // deterministic canonical JSON string
string ComputeDigest();      // 64-character lowercase hex SHA-256 of the canonical JSON

CanonJsonExtensions

The same file exposes static helpers that prefer an object’s own ICanonicalizable implementation when present and otherwise fall back to CanonJson.Hash:

public static string GetDigest<T>(T obj) where T : notnull;          // ComputeDigest() or CanonJson.Hash(obj)
public static string GetPrefixedDigest<T>(T obj) where T : notnull;  // "sha256:" + GetDigest(obj)

IJsonCanonicalizer Interface

Namespace: StellaOps.Attestor.ProofChain.JsonAssembly: StellaOps.Attestor.ProofChain (source: src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/Json/IJsonCanonicalizer.cs)

This interface is not part of the StellaOps.Canonical.Json assembly. It is a byte-oriented canonicalization abstraction owned by the Attestor proof-chain library and is documented here for cross-reference.

Interface for byte-in/byte-out JSON canonicalization implementations.

Methods

Canonicalize

byte[] Canonicalize(ReadOnlySpan<byte> utf8Json)

Canonicalizes UTF-8 JSON bytes per RFC 8785.

Parameters:

Returns: Canonical UTF-8 JSON bytes with sorted keys and no whitespace


CanonicalizeWithVersion

byte[] CanonicalizeWithVersion(ReadOnlySpan<byte> utf8Json, string version)

Canonicalizes UTF-8 JSON bytes with version marker prepended.

Parameters:

Returns: Canonical UTF-8 JSON bytes with _canonVersion field

Implementation: Rfc8785JsonCanonicalizer

StellaOps.Attestor.ProofChain.Json.Rfc8785JsonCanonicalizer is the concrete sealed implementation. Its constructor takes bool enableNfcNormalization = true, controlling whether string values are normalized to Unicode NFC for cross-platform stability. It throws ArgumentException (via ThrowIfNullOrWhiteSpace) when version is null/empty/whitespace. (Source: Rfc8785JsonCanonicalizer.cs.)


Usage Examples

Computing Content-Addressed IDs

using StellaOps.Canonical.Json;

// Evidence predicate hashing
var evidence = new EvidencePredicate
{
    Source = "scanner/trivy",
    SbomEntryId = "sha256:91f2ab3c:pkg:npm/lodash@4.17.21",
    VulnerabilityId = "CVE-2021-23337"
};

// Compute versioned hash (recommended)
string evidenceId = CanonJson.HashVersionedPrefixed(evidence);
// Result: "sha256:..."

Verifying Attestations

public bool VerifyAttestation(byte[] payload, string expectedHash)
{
    // `payload` is ALREADY-canonical bytes. Hash the bytes directly with
    // Sha256Hex — do NOT call Hash<T>/HashVersioned<T>, which serialize a CLR
    // object and would treat a byte[] as a JSON array, producing a wrong hash.
    var computed = CanonJson.Sha256Hex(payload);
    return computed == expectedHash;

    // Detect format only when you need the version, e.g. to re-canonicalize
    // raw (non-canonical) input via CanonJson.CanonicalizeParsedJson first.
}

The Hash<T> / HashVersioned<T> family serializes its obj argument. Use them with CLR objects; use Sha256Hex / CanonicalizeParsedJson when you already hold canonical or raw JSON bytes.

Migration from Legacy to Versioned

// Old code (legacy)
var hash = CanonJson.Hash(predicate);

// New code (versioned) - just add "Versioned"
var hash = CanonJson.HashVersioned(predicate);

Algorithm Details

RFC 8785 Compliance

RequirementImplementation
Key orderingStringComparer.Ordinal (case-sensitive); keys are NFC-normalized before sorting
Number formatUtf8JsonWriter default; negative zero (-0) is normalized to 0 (see below)
String escapingJavaScriptEncoder.UnsafeRelaxedJsonEscaping (relaxed — does not escape <, >, &, +, etc.)
Unicode normalizationBoth property names and string values are normalized to NFC (Form C) when not already normalized
WhitespaceNone (compact output)
EncodingUTF-8 without BOM

Negative-zero normalization. WriteElementSorted checks each number via TryWriteNormalizedNumber: if the value’s IEEE 754 bit pattern is negative zero, it is written as 0. (Source: CanonJson.Writer.cs.)

Unicode NFC. Unlike strict RFC 8785, this implementation additionally normalizes property names and string values to Unicode Normalization Form C, so composed/decomposed equivalents produce identical canonical bytes. The Rfc8785JsonCanonicalizer byte-level implementation makes this optional via enableNfcNormalization. (Source: CanonJson.Writer.cs, Rfc8785JsonCanonicalizer.cs.)

Version Marker Position

The _canonVersion field is always first in the output due to:

  1. Underscore (_) sorts before all letters in ASCII
  2. After injecting version, remaining keys are sorted normally
{"_canonVersion":"stella:canon:v1","aaa":1,"bbb":2,"zzz":3}