stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -57,83 +57,3 @@ public sealed record DeduplicatedEdge
/// </summary>
public bool IsCorroborated => Sources.Count > 1;
}
/// <summary>
/// Builder for creating <see cref="DeduplicatedEdge"/> instances by merging multiple source edges.
/// </summary>
public sealed class DeduplicatedEdgeBuilder
{
private readonly EdgeSemanticKey _key;
private readonly string _from;
private readonly string _to;
private readonly HashSet<string> _sources = new(StringComparer.Ordinal);
private EdgeExplanation? _explanation;
private double _maxStrength;
private DateTimeOffset _lastSeen = DateTimeOffset.MinValue;
/// <summary>
/// Initializes a new builder for the given semantic key.
/// </summary>
public DeduplicatedEdgeBuilder(EdgeSemanticKey key, string from, string to)
{
_key = key;
_from = from;
_to = to;
}
/// <summary>
/// Adds a source edge to this builder.
/// </summary>
/// <param name="sourceId">The source identifier (e.g., feed name, analyzer ID).</param>
/// <param name="explanation">The edge explanation from this source.</param>
/// <param name="strength">The strength/weight from this source.</param>
/// <param name="observedAt">When this source observed the edge.</param>
/// <returns>This builder for chaining.</returns>
public DeduplicatedEdgeBuilder AddSource(
string sourceId,
EdgeExplanation explanation,
double strength,
DateTimeOffset observedAt)
{
_sources.Add(sourceId);
// Keep the strongest explanation
if (strength > _maxStrength || _explanation is null)
{
_maxStrength = strength;
_explanation = explanation;
}
// Track most recent observation
if (observedAt > _lastSeen)
{
_lastSeen = observedAt;
}
return this;
}
/// <summary>
/// Builds the deduplicated edge.
/// </summary>
/// <returns>The deduplicated edge with merged provenance.</returns>
/// <exception cref="InvalidOperationException">If no sources were added.</exception>
public DeduplicatedEdge Build()
{
if (_sources.Count == 0 || _explanation is null)
{
throw new InvalidOperationException("At least one source must be added before building.");
}
return new DeduplicatedEdge
{
Key = _key,
From = _from,
To = _to,
Why = _explanation,
Sources = _sources.ToImmutableHashSet(StringComparer.Ordinal),
Strength = _maxStrength,
LastSeen = _lastSeen
};
}
}

View File

@@ -0,0 +1,83 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Deduplication;
/// <summary>
/// Builder for creating <see cref="DeduplicatedEdge"/> instances by merging multiple source edges.
/// </summary>
public sealed class DeduplicatedEdgeBuilder
{
private readonly EdgeSemanticKey _key;
private readonly string _from;
private readonly string _to;
private readonly HashSet<string> _sources = new(StringComparer.Ordinal);
private EdgeExplanation? _explanation;
private double _maxStrength;
private DateTimeOffset _lastSeen = DateTimeOffset.MinValue;
/// <summary>
/// Initializes a new builder for the given semantic key.
/// </summary>
public DeduplicatedEdgeBuilder(EdgeSemanticKey key, string from, string to)
{
_key = key;
_from = from;
_to = to;
}
/// <summary>
/// Adds a source edge to this builder.
/// </summary>
/// <param name="sourceId">The source identifier (e.g., feed name, analyzer ID).</param>
/// <param name="explanation">The edge explanation from this source.</param>
/// <param name="strength">The strength/weight from this source.</param>
/// <param name="observedAt">When this source observed the edge.</param>
/// <returns>This builder for chaining.</returns>
public DeduplicatedEdgeBuilder AddSource(
string sourceId,
EdgeExplanation explanation,
double strength,
DateTimeOffset observedAt)
{
_sources.Add(sourceId);
if (strength > _maxStrength || _explanation is null)
{
_maxStrength = strength;
_explanation = explanation;
}
if (observedAt > _lastSeen)
{
_lastSeen = observedAt;
}
return this;
}
/// <summary>
/// Builds the deduplicated edge.
/// </summary>
/// <returns>The deduplicated edge with merged provenance.</returns>
/// <exception cref="InvalidOperationException">If no sources were added.</exception>
public DeduplicatedEdge Build()
{
if (_sources.Count == 0 || _explanation is null)
{
throw new InvalidOperationException("At least one source must be added before building.");
}
return new DeduplicatedEdge
{
Key = _key,
From = _from,
To = _to,
Why = _explanation,
Sources = _sources.ToImmutableHashSet(StringComparer.Ordinal),
Strength = _maxStrength,
LastSeen = _lastSeen
};
}
}

View File

@@ -2,32 +2,9 @@
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Deduplication;
/// <summary>
/// Service for deduplicating edges from multiple sources into semantically unique edges.
/// </summary>
public interface IEdgeDeduplicator
{
/// <summary>
/// Deduplicates a collection of edges by their semantic keys.
/// </summary>
/// <param name="edges">The edges to deduplicate.</param>
/// <param name="keyExtractor">Function to extract semantic key from an edge.</param>
/// <param name="sourceExtractor">Function to extract source ID from an edge.</param>
/// <param name="strengthExtractor">Function to extract strength/weight from an edge.</param>
/// <param name="timestampExtractor">Function to extract observation timestamp.</param>
/// <returns>Deduplicated edges with merged provenance.</returns>
IReadOnlyList<DeduplicatedEdge> Deduplicate(
IEnumerable<ReachGraphEdge> edges,
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
Func<ReachGraphEdge, string> sourceExtractor,
Func<ReachGraphEdge, double> strengthExtractor,
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor);
}
/// <summary>
/// Default implementation of <see cref="IEdgeDeduplicator"/>.
/// </summary>
@@ -80,59 +57,3 @@ public sealed class EdgeDeduplicator : IEdgeDeduplicator
.ToList();
}
}
/// <summary>
/// Extensions for edge deduplication.
/// </summary>
public static class EdgeDeduplicatorExtensions
{
/// <summary>
/// Deduplicates edges using default extractors based on edge properties.
/// </summary>
/// <param name="deduplicator">The deduplicator instance.</param>
/// <param name="edges">The edges to deduplicate.</param>
/// <param name="vulnerabilityId">The vulnerability ID to associate with edges.</param>
/// <param name="defaultSource">Default source ID if not specified.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <returns>Deduplicated edges.</returns>
public static IReadOnlyList<DeduplicatedEdge> DeduplicateWithDefaults(
this IEdgeDeduplicator deduplicator,
IEnumerable<ReachGraphEdge> edges,
string vulnerabilityId,
string defaultSource = "unknown",
TimeProvider? timeProvider = null)
{
var time = timeProvider ?? TimeProvider.System;
var now = time.GetUtcNow();
return deduplicator.Deduplicate(
edges,
keyExtractor: e => new EdgeSemanticKey(e.From, e.To, vulnerabilityId),
sourceExtractor: _ => defaultSource,
strengthExtractor: e => GetEdgeStrength(e.Why),
timestampExtractor: _ => now);
}
private static double GetEdgeStrength(EdgeExplanation explanation)
{
// Use the explanation's confidence as the base strength
// Map edge explanation type to a multiplier
var typeMultiplier = explanation.Type switch
{
EdgeExplanationType.DirectCall => 1.0,
EdgeExplanationType.Import => 0.95,
EdgeExplanationType.DynamicLoad => 0.9,
EdgeExplanationType.Ffi => 0.85,
EdgeExplanationType.Reflection => 0.8,
EdgeExplanationType.LoaderRule => 0.75,
EdgeExplanationType.TaintGate => 0.7,
EdgeExplanationType.EnvGuard => 0.65,
EdgeExplanationType.FeatureFlag => 0.6,
EdgeExplanationType.PlatformArch => 0.6,
EdgeExplanationType.Unknown => 0.5,
_ => 0.5
};
return explanation.Confidence * typeMultiplier;
}
}

View File

@@ -0,0 +1,58 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Deduplication;
/// <summary>
/// Extensions for edge deduplication.
/// </summary>
public static class EdgeDeduplicatorExtensions
{
/// <summary>
/// Deduplicates edges using default extractors based on edge properties.
/// </summary>
/// <param name="deduplicator">The deduplicator instance.</param>
/// <param name="edges">The edges to deduplicate.</param>
/// <param name="vulnerabilityId">The vulnerability ID to associate with edges.</param>
/// <param name="defaultSource">Default source ID if not specified.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <returns>Deduplicated edges.</returns>
public static IReadOnlyList<DeduplicatedEdge> DeduplicateWithDefaults(
this IEdgeDeduplicator deduplicator,
IEnumerable<ReachGraphEdge> edges,
string vulnerabilityId,
string defaultSource = "unknown",
TimeProvider? timeProvider = null)
{
var time = timeProvider ?? TimeProvider.System;
var now = time.GetUtcNow();
return deduplicator.Deduplicate(
edges,
keyExtractor: e => new EdgeSemanticKey(e.From, e.To, vulnerabilityId),
sourceExtractor: _ => defaultSource,
strengthExtractor: e => GetEdgeStrength(e.Why),
timestampExtractor: _ => now);
}
private static double GetEdgeStrength(EdgeExplanation explanation)
{
var typeMultiplier = explanation.Type switch
{
EdgeExplanationType.DirectCall => 1.0,
EdgeExplanationType.Import => 0.95,
EdgeExplanationType.DynamicLoad => 0.9,
EdgeExplanationType.Ffi => 0.85,
EdgeExplanationType.Reflection => 0.8,
EdgeExplanationType.LoaderRule => 0.75,
EdgeExplanationType.TaintGate => 0.7,
EdgeExplanationType.EnvGuard => 0.65,
EdgeExplanationType.FeatureFlag => 0.6,
EdgeExplanationType.PlatformArch => 0.6,
EdgeExplanationType.Unknown => 0.5,
_ => 0.5
};
return explanation.Confidence * typeMultiplier;
}
}

View File

@@ -0,0 +1,75 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.ReachGraph.Deduplication;
public readonly partial record struct EdgeSemanticKey
{
/// <summary>
/// Computes a canonical string key for this semantic key.
/// </summary>
/// <returns>A canonical string representation suitable for dictionary keys.</returns>
public string ComputeKey()
{
var builder = new StringBuilder(256);
builder.Append(EntryPointId);
builder.Append('|');
builder.Append(SinkId);
builder.Append('|');
builder.Append(VulnerabilityId ?? string.Empty);
builder.Append('|');
builder.Append(GateApplied ?? string.Empty);
return builder.ToString();
}
/// <summary>
/// Computes a SHA-256 hash of the canonical key for compact storage.
/// </summary>
/// <returns>A lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeHash()
{
var key = ComputeKey();
var bytes = Encoding.UTF8.GetBytes(key);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return HashCode.Combine(
EntryPointId,
SinkId,
VulnerabilityId ?? string.Empty,
GateApplied ?? string.Empty);
}
/// <inheritdoc/>
public bool Equals(EdgeSemanticKey other)
{
return string.Equals(EntryPointId, other.EntryPointId, StringComparison.Ordinal) &&
string.Equals(SinkId, other.SinkId, StringComparison.Ordinal) &&
string.Equals(VulnerabilityId, other.VulnerabilityId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(GateApplied, other.GateApplied, StringComparison.Ordinal);
}
/// <inheritdoc/>
public override string ToString() => ComputeKey();
private static string? NormalizeId(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
if (id.StartsWith("cve-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("CVE-", StringComparison.Ordinal))
{
return id.ToUpperInvariant();
}
return id;
}
}

View File

@@ -1,9 +1,5 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.ReachGraph.Deduplication;
/// <summary>
@@ -20,7 +16,7 @@ namespace StellaOps.ReachGraph.Deduplication;
/// - Vulnerability ID (if applicable)
/// - Applied gate (if any)
/// </remarks>
public readonly record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
public readonly partial record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
{
/// <summary>
/// Gets the entry point node identifier.
@@ -64,71 +60,4 @@ public readonly record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
GateApplied = gateApplied;
}
/// <summary>
/// Computes a canonical string key for this semantic key.
/// </summary>
/// <returns>A canonical string representation suitable for dictionary keys.</returns>
public string ComputeKey()
{
var builder = new StringBuilder(256);
builder.Append(EntryPointId);
builder.Append('|');
builder.Append(SinkId);
builder.Append('|');
builder.Append(VulnerabilityId ?? string.Empty);
builder.Append('|');
builder.Append(GateApplied ?? string.Empty);
return builder.ToString();
}
/// <summary>
/// Computes a SHA-256 hash of the canonical key for compact storage.
/// </summary>
/// <returns>A lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeHash()
{
var key = ComputeKey();
var bytes = Encoding.UTF8.GetBytes(key);
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
/// <inheritdoc/>
public override int GetHashCode()
{
return HashCode.Combine(
EntryPointId,
SinkId,
VulnerabilityId ?? string.Empty,
GateApplied ?? string.Empty);
}
/// <inheritdoc/>
public bool Equals(EdgeSemanticKey other)
{
return string.Equals(EntryPointId, other.EntryPointId, StringComparison.Ordinal) &&
string.Equals(SinkId, other.SinkId, StringComparison.Ordinal) &&
string.Equals(VulnerabilityId, other.VulnerabilityId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(GateApplied, other.GateApplied, StringComparison.Ordinal);
}
/// <inheritdoc/>
public override string ToString() => ComputeKey();
private static string? NormalizeId(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
// Normalize CVE IDs to uppercase for consistent comparison
if (id.StartsWith("cve-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("CVE-", StringComparison.Ordinal))
{
return id.ToUpperInvariant();
}
return id;
}
}

View File

@@ -0,0 +1,26 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Deduplication;
/// <summary>
/// Service for deduplicating edges from multiple sources into semantically unique edges.
/// </summary>
public interface IEdgeDeduplicator
{
/// <summary>
/// Deduplicates a collection of edges by their semantic keys.
/// </summary>
/// <param name="edges">The edges to deduplicate.</param>
/// <param name="keyExtractor">Function to extract semantic key from an edge.</param>
/// <param name="sourceExtractor">Function to extract source ID from an edge.</param>
/// <param name="strengthExtractor">Function to extract strength/weight from an edge.</param>
/// <param name="timestampExtractor">Function to extract observation timestamp.</param>
/// <returns>Deduplicated edges with merged provenance.</returns>
IReadOnlyList<DeduplicatedEdge> Deduplicate(
IEnumerable<ReachGraphEdge> edges,
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
Func<ReachGraphEdge, string> sourceExtractor,
Func<ReachGraphEdge, double> strengthExtractor,
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor);
}

View File

@@ -0,0 +1,50 @@
// Licensed to StellaOps under the BUSL-1.1 license.
namespace StellaOps.ReachGraph.Hashing;
public sealed partial class ReachGraphDigestComputer
{
/// <summary>
/// Parse a digest string into its algorithm and hash components.
/// </summary>
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
public static (string Algorithm, string Hash)? ParseDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
{
return null;
}
var colonIndex = digest.IndexOf(':');
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
{
return null;
}
var algorithm = digest[..colonIndex];
var hash = digest[(colonIndex + 1)..];
return (algorithm, hash);
}
/// <summary>
/// Validate that a digest string has the correct format for BLAKE3.
/// </summary>
/// <param name="digest">The digest string to validate.</param>
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
public static bool IsValidBlake3Digest(string digest)
{
var parsed = ParseDigest(digest);
if (parsed is null)
{
return false;
}
var (algorithm, hash) = parsed.Value;
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
hash.Length == 64 &&
hash.All(static c => char.IsAsciiHexDigit(c));
}
}

View File

@@ -10,7 +10,7 @@ namespace StellaOps.ReachGraph.Hashing;
/// <summary>
/// Computes BLAKE3-256 digests for reachability graphs using canonical serialization.
/// </summary>
public sealed class ReachGraphDigestComputer
public sealed partial class ReachGraphDigestComputer
{
private readonly CanonicalReachGraphSerializer _serializer;
@@ -73,42 +73,4 @@ public sealed class ReachGraphDigestComputer
return string.Equals(computed, expectedDigest, StringComparison.Ordinal);
}
/// <summary>
/// Parse a digest string into its algorithm and hash components.
/// </summary>
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
public static (string Algorithm, string Hash)? ParseDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
return null;
var colonIndex = digest.IndexOf(':');
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
return null;
var algorithm = digest[..colonIndex];
var hash = digest[(colonIndex + 1)..];
return (algorithm, hash);
}
/// <summary>
/// Validate that a digest string has the correct format for BLAKE3.
/// </summary>
/// <param name="digest">The digest string to validate.</param>
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
public static bool IsValidBlake3Digest(string digest)
{
var parsed = ParseDigest(digest);
if (parsed is null)
return false;
var (algorithm, hash) = parsed.Value;
// BLAKE3-256 produces 64 hex characters (32 bytes)
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
hash.Length == 64 &&
hash.All(c => char.IsAsciiHexDigit(c));
}
}

View File

@@ -0,0 +1,77 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private static ReachGraphArtifactDto CreateArtifactDto(ReachGraphArtifact artifact)
{
var sortedEnv = artifact.Env.OrderBy(e => e, StringComparer.Ordinal).ToList();
return new ReachGraphArtifactDto
{
Name = artifact.Name,
Digest = artifact.Digest,
Env = sortedEnv
};
}
private static ReachGraphScopeDto CreateScopeDto(ReachGraphScope scope)
{
var sortedEntrypoints = scope.Entrypoints.OrderBy(e => e, StringComparer.Ordinal).ToList();
var sortedSelectors = scope.Selectors.OrderBy(s => s, StringComparer.Ordinal).ToList();
List<string>? sortedCves = scope.Cves is { Length: > 0 } cves
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
: null;
return new ReachGraphScopeDto
{
Entrypoints = sortedEntrypoints,
Selectors = sortedSelectors,
Cves = sortedCves
};
}
private static ReachGraphProvenanceDto CreateProvenanceDto(ReachGraphProvenance provenance)
{
List<string>? sortedIntoto = provenance.Intoto is { Length: > 0 } intoto
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
: null;
return new ReachGraphProvenanceDto
{
Intoto = sortedIntoto,
Inputs = new ReachGraphInputsDto
{
Sbom = provenance.Inputs.Sbom,
Vex = provenance.Inputs.Vex,
Callgraph = provenance.Inputs.Callgraph,
RuntimeFacts = provenance.Inputs.RuntimeFacts,
Policy = provenance.Inputs.Policy
},
ComputedAt = provenance.ComputedAt,
Analyzer = new ReachGraphAnalyzerDto
{
Name = provenance.Analyzer.Name,
Version = provenance.Analyzer.Version,
ToolchainDigest = provenance.Analyzer.ToolchainDigest
}
};
}
private static List<ReachGraphSignatureDto>? CreateSignatureDtos(
ImmutableArray<ReachGraphSignature>? signatures)
{
if (signatures is not { Length: > 0 } sigs)
{
return null;
}
return sigs
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
.ToList();
}
}

View File

@@ -0,0 +1,50 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private static List<ReachGraphNodeDto> CreateNodeDtos(ImmutableArray<ReachGraphNode> nodes)
{
return nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select(n => new ReachGraphNodeDto
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})
.ToList();
}
private static List<ReachGraphEdgeDto> CreateEdgeDtos(ImmutableArray<ReachGraphEdge> edges)
{
return edges
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.Select(e => new ReachGraphEdgeDto
{
From = e.From,
To = e.To,
Why = new EdgeExplanationDto
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
: null
}
})
.ToList();
}
}

View File

@@ -0,0 +1,24 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
/// <summary>
/// Create a canonical DTO with sorted arrays.
/// </summary>
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
{
return new ReachGraphMinimalDto
{
SchemaVersion = graph.SchemaVersion,
Artifact = CreateArtifactDto(graph.Artifact),
Scope = CreateScopeDto(graph.Scope),
Nodes = CreateNodeDtos(graph.Nodes),
Edges = CreateEdgeDtos(graph.Edges),
Provenance = CreateProvenanceDto(graph.Provenance),
Signatures = CreateSignatureDtos(graph.Signatures)
};
}
}

View File

@@ -0,0 +1,55 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private sealed class ReachGraphMinimalDto
{
[JsonPropertyOrder(1)]
public required string SchemaVersion { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphArtifactDto Artifact { get; init; }
[JsonPropertyOrder(3)]
public required ReachGraphScopeDto Scope { get; init; }
[JsonPropertyOrder(4)]
public required List<ReachGraphNodeDto> Nodes { get; init; }
[JsonPropertyOrder(5)]
public required List<ReachGraphEdgeDto> Edges { get; init; }
[JsonPropertyOrder(6)]
public required ReachGraphProvenanceDto Provenance { get; init; }
[JsonPropertyOrder(7)]
public List<ReachGraphSignatureDto>? Signatures { get; init; }
}
private sealed class ReachGraphArtifactDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Digest { get; init; }
[JsonPropertyOrder(3)]
public required List<string> Env { get; init; }
}
private sealed class ReachGraphScopeDto
{
[JsonPropertyOrder(1)]
public required List<string> Entrypoints { get; init; }
[JsonPropertyOrder(2)]
public required List<string> Selectors { get; init; }
[JsonPropertyOrder(3)]
public List<string>? Cves { get; init; }
}
}

View File

@@ -0,0 +1,68 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private sealed class ReachGraphNodeDto
{
[JsonPropertyOrder(1)]
public required string Id { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphNodeKind Kind { get; init; }
[JsonPropertyOrder(3)]
public required string Ref { get; init; }
[JsonPropertyOrder(4)]
public string? File { get; init; }
[JsonPropertyOrder(5)]
public int? Line { get; init; }
[JsonPropertyOrder(6)]
public string? ModuleHash { get; init; }
[JsonPropertyOrder(7)]
public string? Addr { get; init; }
[JsonPropertyOrder(8)]
public bool? IsEntrypoint { get; init; }
[JsonPropertyOrder(9)]
public bool? IsSink { get; init; }
}
private sealed class ReachGraphEdgeDto
{
[JsonPropertyOrder(1)]
public required string From { get; init; }
[JsonPropertyOrder(2)]
public required string To { get; init; }
[JsonPropertyOrder(3)]
public required EdgeExplanationDto Why { get; init; }
}
private sealed class EdgeExplanationDto
{
[JsonPropertyOrder(1)]
public required EdgeExplanationType Type { get; init; }
[JsonPropertyOrder(2)]
public string? Loc { get; init; }
[JsonPropertyOrder(3)]
public string? Guard { get; init; }
[JsonPropertyOrder(4)]
public required double Confidence { get; init; }
[JsonPropertyOrder(5)]
public SortedDictionary<string, string>? Metadata { get; init; }
}
}

View File

@@ -0,0 +1,61 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private sealed class ReachGraphProvenanceDto
{
[JsonPropertyOrder(1)]
public List<string>? Intoto { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphInputsDto Inputs { get; init; }
[JsonPropertyOrder(3)]
public required DateTimeOffset ComputedAt { get; init; }
[JsonPropertyOrder(4)]
public required ReachGraphAnalyzerDto Analyzer { get; init; }
}
private sealed class ReachGraphInputsDto
{
[JsonPropertyOrder(1)]
public required string Sbom { get; init; }
[JsonPropertyOrder(2)]
public string? Vex { get; init; }
[JsonPropertyOrder(3)]
public string? Callgraph { get; init; }
[JsonPropertyOrder(4)]
public string? RuntimeFacts { get; init; }
[JsonPropertyOrder(5)]
public string? Policy { get; init; }
}
private sealed class ReachGraphAnalyzerDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Version { get; init; }
[JsonPropertyOrder(3)]
public required string ToolchainDigest { get; init; }
}
private sealed class ReachGraphSignatureDto
{
[JsonPropertyOrder(1)]
public required string KeyId { get; init; }
[JsonPropertyOrder(2)]
public required string Sig { get; init; }
}
}

View File

@@ -0,0 +1,53 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private static ReachGraphArtifact CreateArtifact(ReachGraphArtifactDto dto)
{
return new ReachGraphArtifact(dto.Name, dto.Digest, [.. dto.Env]);
}
private static ReachGraphScope CreateScope(ReachGraphScopeDto dto)
{
return new ReachGraphScope(
[.. dto.Entrypoints],
[.. dto.Selectors],
dto.Cves is { Count: > 0 } ? [.. dto.Cves] : null);
}
private static ReachGraphProvenance CreateProvenance(ReachGraphProvenanceDto dto)
{
return new ReachGraphProvenance
{
Intoto = dto.Intoto is { Count: > 0 } ? [.. dto.Intoto] : null,
Inputs = new ReachGraphInputs
{
Sbom = dto.Inputs.Sbom,
Vex = dto.Inputs.Vex,
Callgraph = dto.Inputs.Callgraph,
RuntimeFacts = dto.Inputs.RuntimeFacts,
Policy = dto.Inputs.Policy
},
ComputedAt = dto.ComputedAt,
Analyzer = new ReachGraphAnalyzer(
dto.Analyzer.Name,
dto.Analyzer.Version,
dto.Analyzer.ToolchainDigest)
};
}
private static ImmutableArray<ReachGraphSignature>? CreateSignatures(
List<ReachGraphSignatureDto>? signatures)
{
if (signatures is not { Count: > 0 })
{
return null;
}
return [.. signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))];
}
}

View File

@@ -0,0 +1,43 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private static ImmutableArray<ReachGraphNode> CreateNodes(List<ReachGraphNodeDto> nodes)
{
return [.. nodes.Select(n => new ReachGraphNode
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})];
}
private static ImmutableArray<ReachGraphEdge> CreateEdges(List<ReachGraphEdgeDto> edges)
{
return [.. edges.Select(e => new ReachGraphEdge
{
From = e.From,
To = e.To,
Why = new EdgeExplanation
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? e.Why.Metadata.ToImmutableDictionary()
: null
}
})];
}
}

View File

@@ -0,0 +1,24 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
/// <summary>
/// Convert DTO back to domain record.
/// </summary>
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
{
return new ReachGraphMinimal
{
SchemaVersion = dto.SchemaVersion,
Artifact = CreateArtifact(dto.Artifact),
Scope = CreateScope(dto.Scope),
Nodes = CreateNodes(dto.Nodes),
Edges = CreateEdges(dto.Edges),
Provenance = CreateProvenance(dto.Provenance),
Signatures = CreateSignatures(dto.Signatures)
};
}
}

View File

@@ -0,0 +1,24 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
public sealed partial class CanonicalReachGraphSerializer
{
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
{
return new JsonSerializerOptions
{
WriteIndented = writeIndented,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
new Iso8601MillisecondConverter()
},
PropertyNameCaseInsensitive = true
};
}
}

View File

@@ -1,11 +1,6 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
@@ -18,7 +13,7 @@ namespace StellaOps.ReachGraph.Serialization;
/// - Null fields omitted
/// - Minified output for storage, prettified for debugging
/// </summary>
public sealed class CanonicalReachGraphSerializer
public sealed partial class CanonicalReachGraphSerializer
{
private readonly JsonSerializerOptions _minifiedOptions;
private readonly JsonSerializerOptions _prettyOptions;
@@ -74,390 +69,4 @@ public sealed class CanonicalReachGraphSerializer
return FromDto(dto);
}
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
{
return new JsonSerializerOptions
{
WriteIndented = writeIndented,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
new Iso8601MillisecondConverter()
},
// Ensure consistent ordering
PropertyNameCaseInsensitive = true
};
}
/// <summary>
/// Create a canonical DTO with sorted arrays.
/// </summary>
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
{
// Sort nodes by Id lexicographically
var sortedNodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select(n => new ReachGraphNodeDto
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})
.ToList();
// Sort edges by From, then To
var sortedEdges = graph.Edges
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.Select(e => new ReachGraphEdgeDto
{
From = e.From,
To = e.To,
Why = new EdgeExplanationDto
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
: null
}
})
.ToList();
// Sort signatures by KeyId if present
List<ReachGraphSignatureDto>? sortedSignatures = null;
if (graph.Signatures is { Length: > 0 } sigs)
{
sortedSignatures = sigs
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
.ToList();
}
// Sort entrypoints and selectors
var sortedEntrypoints = graph.Scope.Entrypoints
.OrderBy(e => e, StringComparer.Ordinal)
.ToList();
var sortedSelectors = graph.Scope.Selectors
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
List<string>? sortedCves = graph.Scope.Cves is { Length: > 0 } cves
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
: null;
// Sort env array
var sortedEnv = graph.Artifact.Env
.OrderBy(e => e, StringComparer.Ordinal)
.ToList();
// Sort intoto array if present
List<string>? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
: null;
return new ReachGraphMinimalDto
{
SchemaVersion = graph.SchemaVersion,
Artifact = new ReachGraphArtifactDto
{
Name = graph.Artifact.Name,
Digest = graph.Artifact.Digest,
Env = sortedEnv
},
Scope = new ReachGraphScopeDto
{
Entrypoints = sortedEntrypoints,
Selectors = sortedSelectors,
Cves = sortedCves
},
Nodes = sortedNodes,
Edges = sortedEdges,
Provenance = new ReachGraphProvenanceDto
{
Intoto = sortedIntoto,
Inputs = new ReachGraphInputsDto
{
Sbom = graph.Provenance.Inputs.Sbom,
Vex = graph.Provenance.Inputs.Vex,
Callgraph = graph.Provenance.Inputs.Callgraph,
RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts,
Policy = graph.Provenance.Inputs.Policy
},
ComputedAt = graph.Provenance.ComputedAt,
Analyzer = new ReachGraphAnalyzerDto
{
Name = graph.Provenance.Analyzer.Name,
Version = graph.Provenance.Analyzer.Version,
ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest
}
},
Signatures = sortedSignatures
};
}
/// <summary>
/// Convert DTO back to domain record.
/// </summary>
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
{
return new ReachGraphMinimal
{
SchemaVersion = dto.SchemaVersion,
Artifact = new ReachGraphArtifact(
dto.Artifact.Name,
dto.Artifact.Digest,
[.. dto.Artifact.Env]
),
Scope = new ReachGraphScope(
[.. dto.Scope.Entrypoints],
[.. dto.Scope.Selectors],
dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null
),
Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode
{
Id = n.Id,
Kind = n.Kind,
Ref = n.Ref,
File = n.File,
Line = n.Line,
ModuleHash = n.ModuleHash,
Addr = n.Addr,
IsEntrypoint = n.IsEntrypoint,
IsSink = n.IsSink
})],
Edges = [.. dto.Edges.Select(e => new ReachGraphEdge
{
From = e.From,
To = e.To,
Why = new EdgeExplanation
{
Type = e.Why.Type,
Loc = e.Why.Loc,
Guard = e.Why.Guard,
Confidence = e.Why.Confidence,
Metadata = e.Why.Metadata?.Count > 0
? e.Why.Metadata.ToImmutableDictionary()
: null
}
})],
Provenance = new ReachGraphProvenance
{
Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null,
Inputs = new ReachGraphInputs
{
Sbom = dto.Provenance.Inputs.Sbom,
Vex = dto.Provenance.Inputs.Vex,
Callgraph = dto.Provenance.Inputs.Callgraph,
RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts,
Policy = dto.Provenance.Inputs.Policy
},
ComputedAt = dto.Provenance.ComputedAt,
Analyzer = new ReachGraphAnalyzer(
dto.Provenance.Analyzer.Name,
dto.Provenance.Analyzer.Version,
dto.Provenance.Analyzer.ToolchainDigest
)
},
Signatures = dto.Signatures is { Count: > 0 }
? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))]
: null
};
}
#region DTOs for canonical serialization (alphabetically ordered properties)
private sealed class ReachGraphMinimalDto
{
[JsonPropertyOrder(1)]
public required string SchemaVersion { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphArtifactDto Artifact { get; init; }
[JsonPropertyOrder(3)]
public required ReachGraphScopeDto Scope { get; init; }
[JsonPropertyOrder(4)]
public required List<ReachGraphNodeDto> Nodes { get; init; }
[JsonPropertyOrder(5)]
public required List<ReachGraphEdgeDto> Edges { get; init; }
[JsonPropertyOrder(6)]
public required ReachGraphProvenanceDto Provenance { get; init; }
[JsonPropertyOrder(7)]
public List<ReachGraphSignatureDto>? Signatures { get; init; }
}
private sealed class ReachGraphArtifactDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Digest { get; init; }
[JsonPropertyOrder(3)]
public required List<string> Env { get; init; }
}
private sealed class ReachGraphScopeDto
{
[JsonPropertyOrder(1)]
public required List<string> Entrypoints { get; init; }
[JsonPropertyOrder(2)]
public required List<string> Selectors { get; init; }
[JsonPropertyOrder(3)]
public List<string>? Cves { get; init; }
}
private sealed class ReachGraphNodeDto
{
[JsonPropertyOrder(1)]
public required string Id { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphNodeKind Kind { get; init; }
[JsonPropertyOrder(3)]
public required string Ref { get; init; }
[JsonPropertyOrder(4)]
public string? File { get; init; }
[JsonPropertyOrder(5)]
public int? Line { get; init; }
[JsonPropertyOrder(6)]
public string? ModuleHash { get; init; }
[JsonPropertyOrder(7)]
public string? Addr { get; init; }
[JsonPropertyOrder(8)]
public bool? IsEntrypoint { get; init; }
[JsonPropertyOrder(9)]
public bool? IsSink { get; init; }
}
private sealed class ReachGraphEdgeDto
{
[JsonPropertyOrder(1)]
public required string From { get; init; }
[JsonPropertyOrder(2)]
public required string To { get; init; }
[JsonPropertyOrder(3)]
public required EdgeExplanationDto Why { get; init; }
}
private sealed class EdgeExplanationDto
{
[JsonPropertyOrder(1)]
public required EdgeExplanationType Type { get; init; }
[JsonPropertyOrder(2)]
public string? Loc { get; init; }
[JsonPropertyOrder(3)]
public string? Guard { get; init; }
[JsonPropertyOrder(4)]
public required double Confidence { get; init; }
[JsonPropertyOrder(5)]
public SortedDictionary<string, string>? Metadata { get; init; }
}
private sealed class ReachGraphProvenanceDto
{
[JsonPropertyOrder(1)]
public List<string>? Intoto { get; init; }
[JsonPropertyOrder(2)]
public required ReachGraphInputsDto Inputs { get; init; }
[JsonPropertyOrder(3)]
public required DateTimeOffset ComputedAt { get; init; }
[JsonPropertyOrder(4)]
public required ReachGraphAnalyzerDto Analyzer { get; init; }
}
private sealed class ReachGraphInputsDto
{
[JsonPropertyOrder(1)]
public required string Sbom { get; init; }
[JsonPropertyOrder(2)]
public string? Vex { get; init; }
[JsonPropertyOrder(3)]
public string? Callgraph { get; init; }
[JsonPropertyOrder(4)]
public string? RuntimeFacts { get; init; }
[JsonPropertyOrder(5)]
public string? Policy { get; init; }
}
private sealed class ReachGraphAnalyzerDto
{
[JsonPropertyOrder(1)]
public required string Name { get; init; }
[JsonPropertyOrder(2)]
public required string Version { get; init; }
[JsonPropertyOrder(3)]
public required string ToolchainDigest { get; init; }
}
private sealed class ReachGraphSignatureDto
{
[JsonPropertyOrder(1)]
public required string KeyId { get; init; }
[JsonPropertyOrder(2)]
public required string Sig { get; init; }
}
#endregion
}
/// <summary>
/// JSON converter for ISO-8601 timestamps with millisecond precision.
/// </summary>
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
{
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
// Always output in UTC with millisecond precision
writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,25 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.ReachGraph.Serialization;
/// <summary>
/// JSON converter for ISO-8601 timestamps with millisecond precision.
/// </summary>
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
{
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
return DateTimeOffset.Parse(str, CultureInfo.InvariantCulture);
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,22 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Signing;
public sealed partial class ReachGraphSignerService
{
/// <inheritdoc />
public async Task<byte[]> CreateDsseEnvelopeAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
var signedGraph = await SignAsync(graph, keyId, cancellationToken).ConfigureAwait(false);
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
}
}

View File

@@ -0,0 +1,72 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using StellaOps.ReachGraph.Schema;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
namespace StellaOps.ReachGraph.Signing;
public sealed partial class ReachGraphSignerService
{
/// <summary>
/// Compute DSSE Pre-Authentication Encoding (PAE).
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
/// </summary>
private static byte[] ComputePae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
var offset = 0;
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
offset += prefix.Length;
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
offset += 8;
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
offset += typeBytes.Length;
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
offset += 8;
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
return result;
}
/// <summary>
/// Build a DSSE envelope JSON document.
/// </summary>
private static byte[] BuildDsseEnvelopeJson(
byte[] payload,
ImmutableArray<ReachGraphSignature> signatures)
{
var payloadBase64 = Convert.ToBase64String(payload);
using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms);
writer.WriteStartObject();
writer.WriteString("payloadType", PayloadType);
writer.WriteString("payload", payloadBase64);
writer.WritePropertyName("signatures");
writer.WriteStartArray();
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("keyid", sig.KeyId);
writer.WriteString("sig", sig.Sig);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return ms.ToArray();
}
}

View File

@@ -0,0 +1,42 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Signing;
public sealed partial class ReachGraphSignerService
{
/// <inheritdoc />
public async Task<ReachGraphMinimal> SignAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
var pae = ComputePae(PayloadType, canonicalBytes);
var signatureBytes = await _keyStore
.SignAsync(keyId, pae, cancellationToken)
.ConfigureAwait(false);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
var existingSignatures = graph.Signatures ?? [];
var allSignatures = existingSignatures.Add(newSignature);
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
_logger.LogInformation(
"Signed reachability graph with key {KeyId}, digest {Digest}",
keyId, _digestComputer.ComputeDigest(unsigned));
return graph with { Signatures = allSignatures };
}
}

View File

@@ -0,0 +1,74 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Signing;
public sealed partial class ReachGraphSignerService
{
/// <inheritdoc />
public async Task<ReachGraphVerificationResult> VerifyAsync(
ReachGraphMinimal graph,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
if (graph.Signatures is null or { Length: 0 })
{
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
}
_logger.LogDebug(
"Verifying {Count} signature(s) on reachability graph",
graph.Signatures.Value.Length);
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
var pae = ComputePae(PayloadType, canonicalBytes);
var validKeyIds = new List<string>();
var invalidKeyIds = new List<string>();
foreach (var signature in graph.Signatures.Value)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var signatureBytes = Convert.FromBase64String(signature.Sig);
var isValid = await _keyStore
.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken)
.ConfigureAwait(false);
if (isValid)
{
validKeyIds.Add(signature.KeyId);
}
else
{
invalidKeyIds.Add(signature.KeyId);
}
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
invalidKeyIds.Add(signature.KeyId);
}
}
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
_logger.LogInformation(
"Verification result: {Valid} valid, {Invalid} invalid signatures",
validKeyIds.Count, invalidKeyIds.Count);
return isAllValid
? ReachGraphVerificationResult.Success([.. validKeyIds])
: ReachGraphVerificationResult.Failure(
[.. validKeyIds],
[.. invalidKeyIds],
invalidKeyIds.Count > 0
? $"{invalidKeyIds.Count} signature(s) failed verification"
: null);
}
}

View File

@@ -1,12 +1,7 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.Extensions.Logging;
using StellaOps.ReachGraph.Hashing;
using StellaOps.ReachGraph.Schema;
using StellaOps.ReachGraph.Serialization;
using System.Collections.Immutable;
using System.Text;
namespace StellaOps.ReachGraph.Signing;
@@ -14,10 +9,9 @@ namespace StellaOps.ReachGraph.Signing;
/// DSSE-based signing service for reachability graphs.
/// Wraps the Attestor envelope signing infrastructure.
/// </summary>
public sealed class ReachGraphSignerService : IReachGraphSignerService
public sealed partial class ReachGraphSignerService : IReachGraphSignerService
{
private const string PayloadType = "application/vnd.stellaops.reachgraph.min+json";
private readonly IReachGraphKeyStore _keyStore;
private readonly CanonicalReachGraphSerializer _serializer;
private readonly ReachGraphDigestComputer _digestComputer;
@@ -34,191 +28,4 @@ public sealed class ReachGraphSignerService : IReachGraphSignerService
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ReachGraphMinimal> SignAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
// Get canonical JSON (without existing signatures)
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
// Compute PAE (Pre-Authentication Encoding) for DSSE
var pae = ComputePae(PayloadType, canonicalBytes);
// Sign with the key
var signatureBytes = await _keyStore.SignAsync(keyId, pae, cancellationToken);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
// Create new signature entry
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
// Append to existing signatures (if any) and return
var existingSignatures = graph.Signatures ?? [];
var allSignatures = existingSignatures.Add(newSignature);
// Sort signatures by KeyId for determinism
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
_logger.LogInformation(
"Signed reachability graph with key {KeyId}, digest {Digest}",
keyId, _digestComputer.ComputeDigest(unsigned));
return graph with { Signatures = allSignatures };
}
/// <inheritdoc />
public async Task<ReachGraphVerificationResult> VerifyAsync(
ReachGraphMinimal graph,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
if (graph.Signatures is null or { Length: 0 })
{
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
}
_logger.LogDebug("Verifying {Count} signature(s) on reachability graph", graph.Signatures.Value.Length);
// Get canonical JSON (without signatures)
var unsigned = graph with { Signatures = null };
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
// Compute PAE
var pae = ComputePae(PayloadType, canonicalBytes);
var validKeyIds = new List<string>();
var invalidKeyIds = new List<string>();
foreach (var signature in graph.Signatures.Value)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var signatureBytes = Convert.FromBase64String(signature.Sig);
var isValid = await _keyStore.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken);
if (isValid)
{
validKeyIds.Add(signature.KeyId);
}
else
{
invalidKeyIds.Add(signature.KeyId);
}
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
invalidKeyIds.Add(signature.KeyId);
}
}
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
_logger.LogInformation(
"Verification result: {Valid} valid, {Invalid} invalid signatures",
validKeyIds.Count, invalidKeyIds.Count);
return isAllValid
? ReachGraphVerificationResult.Success([.. validKeyIds])
: ReachGraphVerificationResult.Failure(
[.. validKeyIds],
[.. invalidKeyIds],
invalidKeyIds.Count > 0 ? $"{invalidKeyIds.Count} signature(s) failed verification" : null);
}
/// <inheritdoc />
public async Task<byte[]> CreateDsseEnvelopeAsync(
ReachGraphMinimal graph,
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrEmpty(keyId);
// Sign the graph first
var signedGraph = await SignAsync(graph, keyId, cancellationToken);
// Get canonical JSON for the signed graph
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
// Build DSSE envelope JSON
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
}
/// <summary>
/// Compute DSSE Pre-Authentication Encoding (PAE).
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
/// </summary>
private static byte[] ComputePae(string payloadType, byte[] payload)
{
// PAE format: "DSSEv1" + 8-byte LE length of type + type bytes + 8-byte LE length of payload + payload
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
var offset = 0;
// Copy "DSSEv1 "
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
offset += prefix.Length;
// Write type length as 8-byte LE
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
offset += 8;
// Copy type bytes
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
offset += typeBytes.Length;
// Write payload length as 8-byte LE
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
offset += 8;
// Copy payload
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
return result;
}
/// <summary>
/// Build a DSSE envelope JSON document.
/// </summary>
private static byte[] BuildDsseEnvelopeJson(byte[] payload, ImmutableArray<ReachGraphSignature> signatures)
{
var payloadBase64 = Convert.ToBase64String(payload);
using var ms = new MemoryStream();
using var writer = new System.Text.Json.Utf8JsonWriter(ms);
writer.WriteStartObject();
writer.WriteString("payloadType", PayloadType);
writer.WriteString("payload", payloadBase64);
writer.WritePropertyName("signatures");
writer.WriteStartArray();
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
{
writer.WriteStartObject();
writer.WriteString("keyid", sig.KeyId);
writer.WriteString("sig", sig.Sig);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return ms.ToArray();
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0105-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph core. |
| AUDIT-0105-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-08 | DONE | Split dedup, hashing, serialization, and signing files to <=100 lines, added ConfigureAwait(false) in signing, added dedup/semantic key unit tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj` passed 2026-02-03 (MTP0001 warning). |