sprints work
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of graph root attestation service.
|
||||
/// Creates and verifies DSSE-signed in-toto statements for graph roots.
|
||||
/// </summary>
|
||||
public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
{
|
||||
private const string ToolName = "stellaops/attestor/graph-root";
|
||||
private const string PayloadType = "application/vnd.in-toto+json";
|
||||
|
||||
private static readonly string _toolVersion = GetToolVersion();
|
||||
|
||||
private readonly IMerkleRootComputer _merkleComputer;
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private readonly Func<string?, EnvelopeKey?> _keyResolver;
|
||||
private readonly ILogger<GraphRootAttestor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GraphRootAttestor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="merkleComputer">Service for computing Merkle roots.</param>
|
||||
/// <param name="signatureService">Service for signing envelopes.</param>
|
||||
/// <param name="keyResolver">Function to resolve signing keys by ID.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public GraphRootAttestor(
|
||||
IMerkleRootComputer merkleComputer,
|
||||
EnvelopeSignatureService signatureService,
|
||||
Func<string?, EnvelopeKey?> keyResolver,
|
||||
ILogger<GraphRootAttestor> logger)
|
||||
{
|
||||
_merkleComputer = merkleComputer ?? throw new ArgumentNullException(nameof(merkleComputer));
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
_keyResolver = keyResolver ?? throw new ArgumentNullException(nameof(keyResolver));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GraphRootAttestationResult> AttestAsync(
|
||||
GraphRootAttestationRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Creating graph root attestation for {GraphType} with {NodeCount} nodes and {EdgeCount} edges",
|
||||
request.GraphType,
|
||||
request.NodeIds.Count,
|
||||
request.EdgeIds.Count);
|
||||
|
||||
// 1. Sort node and edge IDs lexicographically for determinism
|
||||
var sortedNodeIds = request.NodeIds
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sortedEdgeIds = request.EdgeIds
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sortedEvidenceIds = request.EvidenceIds
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// 2. Build leaf data for Merkle tree
|
||||
var leaves = BuildLeaves(
|
||||
sortedNodeIds,
|
||||
sortedEdgeIds,
|
||||
request.PolicyDigest,
|
||||
request.FeedsDigest,
|
||||
request.ToolchainDigest,
|
||||
request.ParamsDigest);
|
||||
|
||||
// 3. Compute Merkle root
|
||||
var rootBytes = _merkleComputer.ComputeRoot(leaves);
|
||||
var rootHex = Convert.ToHexStringLower(rootBytes);
|
||||
var rootHash = $"{_merkleComputer.Algorithm}:{rootHex}";
|
||||
|
||||
_logger.LogDebug("Computed Merkle root: {RootHash}", rootHash);
|
||||
|
||||
// 4. Build in-toto statement
|
||||
var computedAt = DateTimeOffset.UtcNow;
|
||||
var attestation = BuildAttestation(
|
||||
request,
|
||||
sortedNodeIds,
|
||||
sortedEdgeIds,
|
||||
sortedEvidenceIds,
|
||||
rootHash,
|
||||
rootHex,
|
||||
computedAt);
|
||||
|
||||
// 5. Canonicalize the attestation
|
||||
var payload = CanonJson.CanonicalizeVersioned(attestation);
|
||||
|
||||
// 6. Sign the payload
|
||||
var key = _keyResolver(request.SigningKeyId);
|
||||
if (key is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unable to resolve signing key: {request.SigningKeyId ?? "(default)"}");
|
||||
}
|
||||
|
||||
var signResult = _signatureService.Sign(payload, key, ct);
|
||||
if (!signResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Signing failed: {signResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var dsseSignature = DsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
|
||||
var envelope = new DsseEnvelope(PayloadType, payload, [dsseSignature]);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created graph root attestation with root {RootHash} for {GraphType}",
|
||||
rootHash,
|
||||
request.GraphType);
|
||||
|
||||
// Note: Rekor publishing would be handled by a separate service
|
||||
// that accepts the envelope after creation
|
||||
|
||||
return new GraphRootAttestationResult
|
||||
{
|
||||
RootHash = rootHash,
|
||||
Envelope = envelope,
|
||||
RekorLogIndex = null, // Would be set by Rekor service
|
||||
NodeCount = sortedNodeIds.Count,
|
||||
EdgeCount = sortedEdgeIds.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GraphRootVerificationResult> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
IReadOnlyList<GraphNodeData> nodes,
|
||||
IReadOnlyList<GraphEdgeData> edges,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(nodes);
|
||||
ArgumentNullException.ThrowIfNull(edges);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying graph root attestation with {NodeCount} nodes and {EdgeCount} edges",
|
||||
nodes.Count,
|
||||
edges.Count);
|
||||
|
||||
// 1. Deserialize attestation from envelope payload
|
||||
GraphRootAttestation? attestation;
|
||||
try
|
||||
{
|
||||
attestation = JsonSerializer.Deserialize<GraphRootAttestation>(envelope.Payload.Span);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = $"Failed to deserialize attestation: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
if (attestation?.Predicate is null)
|
||||
{
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Attestation or predicate is null"
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Sort and recompute
|
||||
var recomputedNodeIds = nodes
|
||||
.Select(n => n.NodeId)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var recomputedEdgeIds = edges
|
||||
.Select(e => e.EdgeId)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// 3. Build leaves using the same inputs from the attestation
|
||||
var leaves = BuildLeaves(
|
||||
recomputedNodeIds,
|
||||
recomputedEdgeIds,
|
||||
attestation.Predicate.Inputs.PolicyDigest,
|
||||
attestation.Predicate.Inputs.FeedsDigest,
|
||||
attestation.Predicate.Inputs.ToolchainDigest,
|
||||
attestation.Predicate.Inputs.ParamsDigest);
|
||||
|
||||
// 4. Compute Merkle root
|
||||
var recomputedRootBytes = _merkleComputer.ComputeRoot(leaves);
|
||||
var recomputedRootHex = Convert.ToHexStringLower(recomputedRootBytes);
|
||||
var recomputedRootHash = $"{_merkleComputer.Algorithm}:{recomputedRootHex}";
|
||||
|
||||
// 5. Compare roots
|
||||
if (!string.Equals(recomputedRootHash, attestation.Predicate.RootHash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Graph root mismatch: expected {Expected}, computed {Computed}",
|
||||
attestation.Predicate.RootHash,
|
||||
recomputedRootHash);
|
||||
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = $"Root mismatch: expected {attestation.Predicate.RootHash}, got {recomputedRootHash}",
|
||||
ExpectedRoot = attestation.Predicate.RootHash,
|
||||
ComputedRoot = recomputedRootHash,
|
||||
NodeCount = recomputedNodeIds.Count,
|
||||
EdgeCount = recomputedEdgeIds.Count
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogDebug("Graph root verification succeeded: {RootHash}", recomputedRootHash);
|
||||
|
||||
return new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ExpectedRoot = attestation.Predicate.RootHash,
|
||||
ComputedRoot = recomputedRootHash,
|
||||
NodeCount = recomputedNodeIds.Count,
|
||||
EdgeCount = recomputedEdgeIds.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ReadOnlyMemory<byte>> BuildLeaves(
|
||||
IReadOnlyList<string> sortedNodeIds,
|
||||
IReadOnlyList<string> sortedEdgeIds,
|
||||
string policyDigest,
|
||||
string feedsDigest,
|
||||
string toolchainDigest,
|
||||
string paramsDigest)
|
||||
{
|
||||
var leaves = new List<ReadOnlyMemory<byte>>(
|
||||
sortedNodeIds.Count + sortedEdgeIds.Count + 4);
|
||||
|
||||
// Add node IDs
|
||||
foreach (var nodeId in sortedNodeIds)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(nodeId));
|
||||
}
|
||||
|
||||
// Add edge IDs
|
||||
foreach (var edgeId in sortedEdgeIds)
|
||||
{
|
||||
leaves.Add(Encoding.UTF8.GetBytes(edgeId));
|
||||
}
|
||||
|
||||
// Add input digests (deterministic order)
|
||||
leaves.Add(Encoding.UTF8.GetBytes(policyDigest));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(toolchainDigest));
|
||||
leaves.Add(Encoding.UTF8.GetBytes(paramsDigest));
|
||||
|
||||
return leaves;
|
||||
}
|
||||
|
||||
private static GraphRootAttestation BuildAttestation(
|
||||
GraphRootAttestationRequest request,
|
||||
IReadOnlyList<string> sortedNodeIds,
|
||||
IReadOnlyList<string> sortedEdgeIds,
|
||||
IReadOnlyList<string> sortedEvidenceIds,
|
||||
string rootHash,
|
||||
string rootHex,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
var subjects = new List<GraphRootSubject>
|
||||
{
|
||||
// Primary subject: the graph root itself
|
||||
new GraphRootSubject
|
||||
{
|
||||
Name = rootHash,
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = rootHex }
|
||||
}
|
||||
};
|
||||
|
||||
// Add artifact subject if provided
|
||||
if (!string.IsNullOrEmpty(request.ArtifactDigest))
|
||||
{
|
||||
subjects.Add(new GraphRootSubject
|
||||
{
|
||||
Name = request.ArtifactDigest,
|
||||
Digest = ParseDigest(request.ArtifactDigest)
|
||||
});
|
||||
}
|
||||
|
||||
return new GraphRootAttestation
|
||||
{
|
||||
Subject = subjects,
|
||||
Predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = request.GraphType.ToString(),
|
||||
RootHash = rootHash,
|
||||
RootAlgorithm = "sha256",
|
||||
NodeCount = sortedNodeIds.Count,
|
||||
EdgeCount = sortedEdgeIds.Count,
|
||||
NodeIds = sortedNodeIds,
|
||||
EdgeIds = sortedEdgeIds,
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = request.PolicyDigest,
|
||||
FeedsDigest = request.FeedsDigest,
|
||||
ToolchainDigest = request.ToolchainDigest,
|
||||
ParamsDigest = request.ParamsDigest
|
||||
},
|
||||
EvidenceIds = sortedEvidenceIds,
|
||||
CanonVersion = CanonVersion.Current,
|
||||
ComputedAt = computedAt,
|
||||
ComputedBy = ToolName,
|
||||
ComputedByVersion = _toolVersion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseDigest(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < digest.Length - 1)
|
||||
{
|
||||
var algorithm = digest[..colonIndex];
|
||||
var value = digest[(colonIndex + 1)..];
|
||||
return new Dictionary<string, string> { [algorithm] = value };
|
||||
}
|
||||
|
||||
// Assume sha256 if no algorithm prefix
|
||||
return new Dictionary<string, string> { ["sha256"] = digest };
|
||||
}
|
||||
|
||||
private static string GetToolVersion()
|
||||
{
|
||||
var assembly = typeof(GraphRootAttestor).Assembly;
|
||||
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "1.0.0";
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering graph root attestation services.
|
||||
/// </summary>
|
||||
public static class GraphRootServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds graph root attestation services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGraphRootAttestation(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
|
||||
services.TryAddSingleton<EnvelopeSignatureService>();
|
||||
services.TryAddSingleton<IGraphRootAttestor, GraphRootAttestor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds graph root attestation services with a custom key resolver.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="keyResolver">Function to resolve signing keys by ID.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGraphRootAttestation(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, Func<string?, EnvelopeKey?>> keyResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyResolver);
|
||||
|
||||
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
|
||||
services.TryAddSingleton<EnvelopeSignatureService>();
|
||||
services.AddSingleton<IGraphRootAttestor>(sp =>
|
||||
{
|
||||
var merkleComputer = sp.GetRequiredService<IMerkleRootComputer>();
|
||||
var signatureService = sp.GetRequiredService<EnvelopeSignatureService>();
|
||||
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<GraphRootAttestor>>();
|
||||
var resolver = keyResolver(sp);
|
||||
|
||||
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// <copyright file="GraphType.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Types of graphs that can have their roots attested.
|
||||
/// </summary>
|
||||
public enum GraphType
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or unspecified graph type.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Call graph showing function/method invocation relationships.
|
||||
/// Used for reachability analysis.
|
||||
/// </summary>
|
||||
CallGraph = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Dependency graph showing package/library dependencies.
|
||||
/// </summary>
|
||||
DependencyGraph = 2,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component graph with artifact relationships.
|
||||
/// </summary>
|
||||
SbomGraph = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence graph linking vulnerabilities to evidence records.
|
||||
/// </summary>
|
||||
EvidenceGraph = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation graph showing rule evaluation paths.
|
||||
/// </summary>
|
||||
PolicyGraph = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Proof spine graph representing the chain of evidence segments.
|
||||
/// </summary>
|
||||
ProofSpine = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Combined reachability graph (call graph + dependency graph).
|
||||
/// </summary>
|
||||
ReachabilityGraph = 7,
|
||||
|
||||
/// <summary>
|
||||
/// VEX observation linkage graph.
|
||||
/// </summary>
|
||||
VexLinkageGraph = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Custom/user-defined graph type.
|
||||
/// </summary>
|
||||
Custom = 100
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying graph root attestations.
|
||||
/// Graph root attestations bind a Merkle root computed from sorted node/edge IDs
|
||||
/// and input digests to a signed DSSE envelope with an in-toto statement.
|
||||
/// </summary>
|
||||
public interface IGraphRootAttestor
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a graph root attestation.
|
||||
/// </summary>
|
||||
/// <param name="request">The attestation request containing graph data and signing options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result containing the root hash and signed envelope.</returns>
|
||||
Task<GraphRootAttestationResult> AttestAsync(
|
||||
GraphRootAttestationRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a graph root attestation against provided graph data.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="nodes">The graph nodes to verify against.</param>
|
||||
/// <param name="edges">The graph edges to verify against.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<GraphRootVerificationResult> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
IReadOnlyList<GraphNodeData> nodes,
|
||||
IReadOnlyList<GraphEdgeData> edges,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Service for computing Merkle tree roots from leaf data.
|
||||
/// </summary>
|
||||
public interface IMerkleRootComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute a Merkle root from the given leaves.
|
||||
/// </summary>
|
||||
/// <param name="leaves">The leaf data in order.</param>
|
||||
/// <returns>The computed root hash bytes.</returns>
|
||||
byte[] ComputeRoot(IReadOnlyList<ReadOnlyMemory<byte>> leaves);
|
||||
|
||||
/// <summary>
|
||||
/// The hash algorithm used for Merkle computation.
|
||||
/// </summary>
|
||||
string Algorithm { get; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement for graph root attestation.
|
||||
/// PredicateType: "https://stella-ops.org/attestation/graph-root/v1"
|
||||
/// </summary>
|
||||
public sealed record GraphRootAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// In-toto statement type URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Subjects: the graph root hash and artifact it describes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<GraphRootSubject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type for graph root attestations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = GraphRootPredicateTypes.GraphRootV1;
|
||||
|
||||
/// <summary>
|
||||
/// Graph root predicate payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required GraphRootPredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in an in-toto statement, representing an artifact or root hash.
|
||||
/// </summary>
|
||||
public sealed record GraphRootSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject.
|
||||
/// For graph roots, this is typically the root hash.
|
||||
/// For artifacts, this is the artifact reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known predicate type URIs for graph root attestations.
|
||||
/// </summary>
|
||||
public static class GraphRootPredicateTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Graph root attestation predicate type v1.
|
||||
/// </summary>
|
||||
public const string GraphRootV1 = "https://stella-ops.org/attestation/graph-root/v1";
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a graph root attestation.
|
||||
/// The attestation binds a Merkle root computed from sorted node/edge IDs
|
||||
/// and input digests to a DSSE envelope with in-toto statement.
|
||||
/// </summary>
|
||||
public sealed record GraphRootAttestationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of graph being attested.
|
||||
/// </summary>
|
||||
public required GraphType GraphType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node IDs to include in the root computation.
|
||||
/// Will be sorted lexicographically for deterministic ordering.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> NodeIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edge IDs to include in the root computation.
|
||||
/// Will be sorted lexicographically for deterministic ordering.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> EdgeIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle digest used during graph computation.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed snapshot digest used during graph computation.
|
||||
/// </summary>
|
||||
public required string FeedsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Toolchain digest (scanner versions, analyzers, etc.).
|
||||
/// </summary>
|
||||
public required string ToolchainDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation parameters digest (config, thresholds, etc.).
|
||||
/// </summary>
|
||||
public required string ParamsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest this graph describes (container image, SBOM, etc.).
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linked evidence IDs referenced by this graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> EvidenceIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish the attestation to a Rekor transparency log.
|
||||
/// </summary>
|
||||
public bool PublishToRekor { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Signing key ID to use for the DSSE envelope.
|
||||
/// If null, the default signing key will be used.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Predicate for graph root attestations.
|
||||
/// Contains the computed Merkle root and all inputs needed for reproducibility.
|
||||
/// </summary>
|
||||
public sealed record GraphRootPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of graph that was attested.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graphType")]
|
||||
public required string GraphType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root hash in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootHash")]
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash algorithm used (e.g., "sha256").
|
||||
/// </summary>
|
||||
[JsonPropertyName("rootAlgorithm")]
|
||||
public string RootAlgorithm { get; init; } = "sha256";
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes included in the root computation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodeCount")]
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges included in the root computation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edgeCount")]
|
||||
public required int EdgeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sorted node IDs for deterministic verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nodeIds")]
|
||||
public required IReadOnlyList<string> NodeIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sorted edge IDs for deterministic verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edgeIds")]
|
||||
public required IReadOnlyList<string> EdgeIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input digests for reproducibility verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputs")]
|
||||
public required GraphInputDigests Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linked evidence IDs referenced by this graph.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceIds")]
|
||||
public IReadOnlyList<string> EvidenceIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizer version used for serialization.
|
||||
/// </summary>
|
||||
[JsonPropertyName("canonVersion")]
|
||||
public required string CanonVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the root was computed (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedAt")]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool that computed the root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedBy")]
|
||||
public required string ComputedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tool version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("computedByVersion")]
|
||||
public required string ComputedByVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input digests for graph computation, enabling reproducibility verification.
|
||||
/// </summary>
|
||||
public sealed record GraphInputDigests
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy bundle digest used during graph computation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feed snapshot digest used during graph computation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedsDigest")]
|
||||
public required string FeedsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Toolchain digest (scanner versions, analyzers, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("toolchainDigest")]
|
||||
public required string ToolchainDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation parameters digest (config, thresholds, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("paramsDigest")]
|
||||
public required string ParamsDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a graph root attestation.
|
||||
/// </summary>
|
||||
public sealed record GraphRootAttestationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Computed Merkle root hash in algorithm:hex format.
|
||||
/// </summary>
|
||||
public required string RootHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signed DSSE envelope containing the in-toto statement.
|
||||
/// </summary>
|
||||
public required DsseEnvelope Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if the attestation was published to transparency log.
|
||||
/// </summary>
|
||||
public string? RekorLogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes included in the root computation.
|
||||
/// </summary>
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges included in the root computation.
|
||||
/// </summary>
|
||||
public required int EdgeCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a graph root attestation.
|
||||
/// </summary>
|
||||
public sealed record GraphRootVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure reason if verification failed.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected root hash from the attestation.
|
||||
/// </summary>
|
||||
public string? ExpectedRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recomputed root hash from the provided graph data.
|
||||
/// </summary>
|
||||
public string? ComputedRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of nodes verified.
|
||||
/// </summary>
|
||||
public int? NodeCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of edges verified.
|
||||
/// </summary>
|
||||
public int? EdgeCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node data for verification.
|
||||
/// </summary>
|
||||
public sealed record GraphNodeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Node identifier.
|
||||
/// </summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional node content for extended verification.
|
||||
/// </summary>
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge data for verification.
|
||||
/// </summary>
|
||||
public sealed record GraphEdgeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge identifier.
|
||||
/// </summary>
|
||||
public required string EdgeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source node identifier.
|
||||
/// </summary>
|
||||
public string? SourceNodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target node identifier.
|
||||
/// </summary>
|
||||
public string? TargetNodeId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Default SHA-256 Merkle root computer using binary tree construction.
|
||||
/// </summary>
|
||||
public sealed class Sha256MerkleRootComputer : IMerkleRootComputer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Algorithm => "sha256";
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] ComputeRoot(IReadOnlyList<ReadOnlyMemory<byte>> leaves)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leaves);
|
||||
|
||||
if (leaves.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one leaf is required to compute a Merkle root.", nameof(leaves));
|
||||
}
|
||||
|
||||
// Hash each leaf to create the initial level
|
||||
var currentLevel = new List<byte[]>(leaves.Count);
|
||||
foreach (var leaf in leaves)
|
||||
{
|
||||
currentLevel.Add(SHA256.HashData(leaf.Span));
|
||||
}
|
||||
|
||||
// Build tree bottom-up
|
||||
while (currentLevel.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>((currentLevel.Count + 1) / 2);
|
||||
|
||||
for (var i = 0; i < currentLevel.Count; i += 2)
|
||||
{
|
||||
var left = currentLevel[i];
|
||||
// If odd number of nodes, duplicate the last one
|
||||
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
|
||||
|
||||
// Combine and hash
|
||||
var combined = new byte[left.Length + right.Length];
|
||||
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
|
||||
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
|
||||
|
||||
nextLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return currentLevel[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
|
||||
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
public class GraphRootAttestorTests
|
||||
{
|
||||
private readonly Mock<IMerkleRootComputer> _merkleComputerMock;
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private readonly GraphRootAttestor _attestor;
|
||||
private readonly EnvelopeKey _testKey;
|
||||
|
||||
public GraphRootAttestorTests()
|
||||
{
|
||||
_merkleComputerMock = new Mock<IMerkleRootComputer>();
|
||||
_merkleComputerMock.Setup(m => m.Algorithm).Returns("sha256");
|
||||
_merkleComputerMock
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Returns(new byte[32]); // 32-byte hash
|
||||
|
||||
// Create a real test key for signing (need both private and public for Ed25519)
|
||||
var privateKey = new byte[64]; // Ed25519 expanded private key is 64 bytes
|
||||
var publicKey = new byte[32];
|
||||
Random.Shared.NextBytes(privateKey);
|
||||
Random.Shared.NextBytes(publicKey);
|
||||
_testKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-key-id");
|
||||
|
||||
_signatureService = new EnvelopeSignatureService();
|
||||
|
||||
_attestor = new GraphRootAttestor(
|
||||
_merkleComputerMock.Object,
|
||||
_signatureService,
|
||||
_ => _testKey,
|
||||
NullLogger<GraphRootAttestor>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_ValidRequest_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.StartsWith("sha256:", result.RootHash);
|
||||
Assert.Equal(3, result.NodeCount);
|
||||
Assert.Equal(2, result.EdgeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_SortsNodeIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = new[] { "z-node", "a-node", "m-node" },
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr",
|
||||
ArtifactDigest = "sha256:a"
|
||||
};
|
||||
|
||||
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
|
||||
_merkleComputerMock
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
|
||||
.Returns(new byte[32]);
|
||||
|
||||
// Act
|
||||
await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedLeaves);
|
||||
// First three leaves should be node IDs in sorted order
|
||||
var firstNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[0].Span);
|
||||
var secondNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[1].Span);
|
||||
var thirdNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[2].Span);
|
||||
Assert.Equal("a-node", firstNodeId);
|
||||
Assert.Equal("m-node", secondNodeId);
|
||||
Assert.Equal("z-node", thirdNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_SortsEdgeIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = Array.Empty<string>(),
|
||||
EdgeIds = new[] { "z-edge", "a-edge" },
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr",
|
||||
ArtifactDigest = "sha256:a"
|
||||
};
|
||||
|
||||
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
|
||||
_merkleComputerMock
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
|
||||
.Returns(new byte[32]);
|
||||
|
||||
// Act
|
||||
await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedLeaves);
|
||||
// First two leaves should be edge IDs in sorted order
|
||||
var firstEdgeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[0].Span);
|
||||
var secondEdgeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[1].Span);
|
||||
Assert.Equal("a-edge", firstEdgeId);
|
||||
Assert.Equal("z-edge", secondEdgeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_IncludesInputDigestsInLeaves()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = Array.Empty<string>(),
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
PolicyDigest = "sha256:policy",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
ToolchainDigest = "sha256:toolchain",
|
||||
ParamsDigest = "sha256:params",
|
||||
ArtifactDigest = "sha256:artifact"
|
||||
};
|
||||
|
||||
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
|
||||
_merkleComputerMock
|
||||
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
|
||||
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
|
||||
.Returns(new byte[32]);
|
||||
|
||||
// Act
|
||||
await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedLeaves);
|
||||
Assert.Equal(4, capturedLeaves.Count); // Just the 4 input digests
|
||||
var digestStrings = capturedLeaves.Select(l => System.Text.Encoding.UTF8.GetString(l.Span)).ToList();
|
||||
Assert.Contains("sha256:policy", digestStrings);
|
||||
Assert.Contains("sha256:feeds", digestStrings);
|
||||
Assert.Contains("sha256:toolchain", digestStrings);
|
||||
Assert.Contains("sha256:params", digestStrings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_NullRequest_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => _attestor.AttestAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_KeyResolverReturnsNull_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var attestorWithNullKey = new GraphRootAttestor(
|
||||
_merkleComputerMock.Object,
|
||||
_signatureService,
|
||||
_ => null,
|
||||
NullLogger<GraphRootAttestor>.Instance);
|
||||
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => attestorWithNullKey.AttestAsync(request));
|
||||
Assert.Contains("Unable to resolve signing key", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_CancellationRequested_ThrowsOperationCanceledException()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateValidRequest();
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => _attestor.AttestAsync(request, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_ReturnsCorrectGraphType()
|
||||
{
|
||||
// Arrange
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.ReachabilityGraph,
|
||||
NodeIds = new[] { "n1" },
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr",
|
||||
ArtifactDigest = "sha256:a"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
var attestation = JsonSerializer.Deserialize<GraphRootAttestation>(result.Envelope.Payload.Span);
|
||||
Assert.NotNull(attestation);
|
||||
Assert.Equal("ReachabilityGraph", attestation.Predicate.GraphType);
|
||||
}
|
||||
|
||||
private static GraphRootAttestationRequest CreateValidRequest()
|
||||
{
|
||||
return new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = new[] { "node-1", "node-2", "node-3" },
|
||||
EdgeIds = new[] { "edge-1", "edge-2" },
|
||||
PolicyDigest = "sha256:policy123",
|
||||
FeedsDigest = "sha256:feeds456",
|
||||
ToolchainDigest = "sha256:tools789",
|
||||
ParamsDigest = "sha256:params012",
|
||||
ArtifactDigest = "sha256:artifact345"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
public class GraphRootModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GraphRootAttestationRequest_RequiredProperties_Set()
|
||||
{
|
||||
// Arrange & Act
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = new[] { "node-1", "node-2" },
|
||||
EdgeIds = new[] { "edge-1" },
|
||||
PolicyDigest = "sha256:abc123",
|
||||
FeedsDigest = "sha256:def456",
|
||||
ToolchainDigest = "sha256:ghi789",
|
||||
ParamsDigest = "sha256:jkl012",
|
||||
ArtifactDigest = "sha256:artifact123"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal(GraphType.DependencyGraph, request.GraphType);
|
||||
Assert.Equal(2, request.NodeIds.Count);
|
||||
Assert.Single(request.EdgeIds);
|
||||
Assert.Equal("sha256:abc123", request.PolicyDigest);
|
||||
Assert.False(request.PublishToRekor);
|
||||
Assert.Null(request.SigningKeyId);
|
||||
Assert.Empty(request.EvidenceIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootAttestationRequest_OptionalProperties_HaveDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var request = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.CallGraph,
|
||||
NodeIds = Array.Empty<string>(),
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr",
|
||||
ArtifactDigest = "sha256:a"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.False(request.PublishToRekor);
|
||||
Assert.Null(request.SigningKeyId);
|
||||
Assert.Empty(request.EvidenceIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootPredicate_RequiredProperties_Set()
|
||||
{
|
||||
// Arrange & Act
|
||||
var predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "DependencyGraph",
|
||||
RootHash = "sha256:abc123",
|
||||
NodeCount = 10,
|
||||
EdgeCount = 15,
|
||||
NodeIds = new[] { "n1", "n2" },
|
||||
EdgeIds = new[] { "e1" },
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr"
|
||||
},
|
||||
CanonVersion = "stella:canon:v1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test",
|
||||
ComputedByVersion = "1.0.0"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("DependencyGraph", predicate.GraphType);
|
||||
Assert.Equal("sha256:abc123", predicate.RootHash);
|
||||
Assert.Equal("sha256", predicate.RootAlgorithm);
|
||||
Assert.Equal(10, predicate.NodeCount);
|
||||
Assert.Equal(15, predicate.EdgeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootAttestation_HasCorrectDefaults()
|
||||
{
|
||||
// Arrange & Act
|
||||
var attestation = new GraphRootAttestation
|
||||
{
|
||||
Subject = new[]
|
||||
{
|
||||
new GraphRootSubject
|
||||
{
|
||||
Name = "sha256:root",
|
||||
Digest = new Dictionary<string, string> { ["sha256"] = "root" }
|
||||
}
|
||||
},
|
||||
Predicate = new GraphRootPredicate
|
||||
{
|
||||
GraphType = "Test",
|
||||
RootHash = "sha256:root",
|
||||
NodeCount = 1,
|
||||
EdgeCount = 0,
|
||||
NodeIds = Array.Empty<string>(),
|
||||
EdgeIds = Array.Empty<string>(),
|
||||
Inputs = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:p",
|
||||
FeedsDigest = "sha256:f",
|
||||
ToolchainDigest = "sha256:t",
|
||||
ParamsDigest = "sha256:pr"
|
||||
},
|
||||
CanonVersion = "v1",
|
||||
ComputedAt = DateTimeOffset.UtcNow,
|
||||
ComputedBy = "test",
|
||||
ComputedByVersion = "1.0"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("https://in-toto.io/Statement/v1", attestation.Type);
|
||||
Assert.Equal(GraphRootPredicateTypes.GraphRootV1, attestation.PredicateType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootPredicateTypes_HasCorrectValue()
|
||||
{
|
||||
Assert.Equal("https://stella-ops.org/attestation/graph-root/v1", GraphRootPredicateTypes.GraphRootV1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootVerificationResult_ValidResult()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ExpectedRoot = "sha256:abc",
|
||||
ComputedRoot = "sha256:abc",
|
||||
NodeCount = 5,
|
||||
EdgeCount = 3
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.FailureReason);
|
||||
Assert.Equal("sha256:abc", result.ExpectedRoot);
|
||||
Assert.Equal(5, result.NodeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphRootVerificationResult_InvalidResult_HasReason()
|
||||
{
|
||||
// Arrange & Act
|
||||
var result = new GraphRootVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
FailureReason = "Root mismatch",
|
||||
ExpectedRoot = "sha256:abc",
|
||||
ComputedRoot = "sha256:xyz"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("Root mismatch", result.FailureReason);
|
||||
Assert.NotEqual(result.ExpectedRoot, result.ComputedRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphNodeData_RequiredProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var node = new GraphNodeData
|
||||
{
|
||||
NodeId = "node-123",
|
||||
Content = "optional content"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("node-123", node.NodeId);
|
||||
Assert.Equal("optional content", node.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphEdgeData_AllProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
var edge = new GraphEdgeData
|
||||
{
|
||||
EdgeId = "edge-1",
|
||||
SourceNodeId = "source-node",
|
||||
TargetNodeId = "target-node"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("edge-1", edge.EdgeId);
|
||||
Assert.Equal("source-node", edge.SourceNodeId);
|
||||
Assert.Equal("target-node", edge.TargetNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphInputDigests_AllDigests()
|
||||
{
|
||||
// Arrange & Act
|
||||
var digests = new GraphInputDigests
|
||||
{
|
||||
PolicyDigest = "sha256:policy",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
ToolchainDigest = "sha256:toolchain",
|
||||
ParamsDigest = "sha256:params"
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.Equal("sha256:policy", digests.PolicyDigest);
|
||||
Assert.Equal("sha256:feeds", digests.FeedsDigest);
|
||||
Assert.Equal("sha256:toolchain", digests.ToolchainDigest);
|
||||
Assert.Equal("sha256:params", digests.ParamsDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
public class Sha256MerkleRootComputerTests
|
||||
{
|
||||
private readonly Sha256MerkleRootComputer _computer = new();
|
||||
|
||||
[Fact]
|
||||
public void Algorithm_ReturnsSha256()
|
||||
{
|
||||
Assert.Equal("sha256", _computer.Algorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_SingleLeaf_ReturnsHash()
|
||||
{
|
||||
// Arrange
|
||||
var leaf = "test-node-1"u8.ToArray();
|
||||
var leaves = new List<ReadOnlyMemory<byte>> { leaf };
|
||||
|
||||
// Act
|
||||
var root = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(32, root.Length); // SHA-256 produces 32 bytes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_TwoLeaves_CombinesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var leaf1 = "node-1"u8.ToArray();
|
||||
var leaf2 = "node-2"u8.ToArray();
|
||||
var leaves = new List<ReadOnlyMemory<byte>> { leaf1, leaf2 };
|
||||
|
||||
// Act
|
||||
var root = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(32, root.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_OddLeaves_DuplicatesLast()
|
||||
{
|
||||
// Arrange
|
||||
var leaves = new List<ReadOnlyMemory<byte>>
|
||||
{
|
||||
"node-1"u8.ToArray(),
|
||||
"node-2"u8.ToArray(),
|
||||
"node-3"u8.ToArray()
|
||||
};
|
||||
|
||||
// Act
|
||||
var root = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(32, root.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_Deterministic_SameInputSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var leaves = new List<ReadOnlyMemory<byte>>
|
||||
{
|
||||
"node-a"u8.ToArray(),
|
||||
"node-b"u8.ToArray(),
|
||||
"edge-1"u8.ToArray(),
|
||||
"edge-2"u8.ToArray()
|
||||
};
|
||||
|
||||
// Act
|
||||
var root1 = _computer.ComputeRoot(leaves);
|
||||
var root2 = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(root1, root2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_DifferentInputs_DifferentOutputs()
|
||||
{
|
||||
// Arrange
|
||||
var leaves1 = new List<ReadOnlyMemory<byte>> { "node-1"u8.ToArray() };
|
||||
var leaves2 = new List<ReadOnlyMemory<byte>> { "node-2"u8.ToArray() };
|
||||
|
||||
// Act
|
||||
var root1 = _computer.ComputeRoot(leaves1);
|
||||
var root2 = _computer.ComputeRoot(leaves2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(root1, root2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_OrderMatters()
|
||||
{
|
||||
// Arrange
|
||||
var leavesAB = new List<ReadOnlyMemory<byte>>
|
||||
{
|
||||
"node-a"u8.ToArray(),
|
||||
"node-b"u8.ToArray()
|
||||
};
|
||||
var leavesBA = new List<ReadOnlyMemory<byte>>
|
||||
{
|
||||
"node-b"u8.ToArray(),
|
||||
"node-a"u8.ToArray()
|
||||
};
|
||||
|
||||
// Act
|
||||
var rootAB = _computer.ComputeRoot(leavesAB);
|
||||
var rootBA = _computer.ComputeRoot(leavesBA);
|
||||
|
||||
// Assert - order should matter for Merkle trees
|
||||
Assert.NotEqual(rootAB, rootBA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_EmptyList_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var leaves = new List<ReadOnlyMemory<byte>>();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => _computer.ComputeRoot(leaves));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => _computer.ComputeRoot(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_LargeTree_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - create 100 leaves
|
||||
var leaves = new List<ReadOnlyMemory<byte>>();
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
leaves.Add(System.Text.Encoding.UTF8.GetBytes($"node-{i:D4}"));
|
||||
}
|
||||
|
||||
// Act
|
||||
var root = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(32, root.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRoot_PowerOfTwo_HandlesCorrectly()
|
||||
{
|
||||
// Arrange - 8 leaves (power of 2)
|
||||
var leaves = new List<ReadOnlyMemory<byte>>();
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
leaves.Add(System.Text.Encoding.UTF8.GetBytes($"node-{i}"));
|
||||
}
|
||||
|
||||
// Act
|
||||
var root = _computer.ComputeRoot(leaves);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(root);
|
||||
Assert.Equal(32, root.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user