save progress
This commit is contained in:
@@ -190,10 +190,8 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
// Sort items deterministically by digest
|
||||
var sortedItems = items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
// Sort items deterministically by digest and stable tie-breakers
|
||||
var sortedItems = TrustEvidenceOrdering.OrderItems(items).ToList();
|
||||
|
||||
if (sortedItems.Count == 0)
|
||||
{
|
||||
@@ -328,6 +326,21 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TrustEvidenceOrdering
|
||||
{
|
||||
public static IOrderedEnumerable<TrustEvidenceItem> OrderItems(IEnumerable<TrustEvidenceItem> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
return items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Type, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.CollectedAt?.ToUniversalTime());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for TrustEvidenceMerkleTree.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
@@ -21,13 +20,11 @@ namespace StellaOps.Attestor.TrustVerdict;
|
||||
/// </remarks>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_canonicalOptions = new()
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new SortedObjectConverter() }
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -35,12 +32,8 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize<T>(T value)
|
||||
{
|
||||
// First serialize to JSON document to get raw structure
|
||||
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
|
||||
|
||||
// Re-parse and canonicalize
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
var json = JsonSerializer.Serialize(value, CanonicalOptions);
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +41,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
return StellaOps.Attestor.StandardPredicates.JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,146 +49,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
WriteCanonical(writer, element);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
WriteCanonicalNumber(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Sort properties lexicographically by key
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must be represented without exponent notation
|
||||
// and with minimal significant digits
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
writer.WriteNumberValue(longValue);
|
||||
}
|
||||
else if (element.TryGetDecimal(out var decimalValue))
|
||||
{
|
||||
// Normalize to remove trailing zeros
|
||||
writer.WriteNumberValue(decimalValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom converter that ensures object properties are sorted.
|
||||
/// </summary>
|
||||
private sealed class SortedObjectConverter : JsonConverter<object>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotSupportedException("Deserialization not supported");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
// Get all public properties, sort by name
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propValue = property.GetValue(value);
|
||||
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
|
||||
writer.WritePropertyName(name);
|
||||
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
var json = element.GetRawText();
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
try
|
||||
{
|
||||
// Parse reference
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return new TrustVerdictOciAttachResult
|
||||
@@ -154,18 +154,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
// 2. Create artifact manifest referencing the blob
|
||||
// 3. Push manifest with subject pointing to original image
|
||||
|
||||
_logger.LogInformation(
|
||||
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
// Placeholder - full implementation requires OCI client
|
||||
var mockDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
_logger.LogWarning(
|
||||
"OCI attachment is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = true,
|
||||
OciDigest = mockDigest,
|
||||
ManifestDigest = mockDigest,
|
||||
Success = false,
|
||||
ErrorMessage = "OCI attachment is not implemented.",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
@@ -195,19 +191,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query referrers API
|
||||
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
|
||||
|
||||
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
|
||||
|
||||
// Placeholder
|
||||
_logger.LogWarning("OCI fetch is enabled but not implemented for {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -230,15 +221,13 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query referrers API and filter by artifact type
|
||||
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
|
||||
|
||||
_logger.LogWarning("OCI list is enabled but not implemented for {Reference}", imageReference);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -262,10 +251,9 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
// DELETE the referrer manifest
|
||||
_logger.LogDebug(
|
||||
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
_logger.LogWarning(
|
||||
"OCI detach is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -276,38 +264,56 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
}
|
||||
}
|
||||
|
||||
private static OciReference? ParseReference(string reference)
|
||||
private static OciReference? ParseReference(string reference, string? defaultRegistry)
|
||||
{
|
||||
// Parse: registry/repo:tag or registry/repo@sha256:digest
|
||||
// Parse: registry/repo:tag, registry/repo@sha256:digest, repo:tag, repo@sha256:digest
|
||||
try
|
||||
{
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
var trimmed = reference.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var atIdx = trimmed.LastIndexOf('@');
|
||||
var digest = atIdx >= 0 ? trimmed[(atIdx + 1)..] : null;
|
||||
var namePart = atIdx >= 0 ? trimmed[..atIdx] : trimmed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(namePart))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? tag = null;
|
||||
var lastSlash = namePart.LastIndexOf('/');
|
||||
var lastColon = namePart.LastIndexOf(':');
|
||||
if (lastColon > lastSlash)
|
||||
{
|
||||
tag = namePart[(lastColon + 1)..];
|
||||
namePart = namePart[..lastColon];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
if (atIdx > 0)
|
||||
var slashIdx = namePart.IndexOf('/');
|
||||
if (slashIdx > 0)
|
||||
{
|
||||
// Has digest
|
||||
digest = reference[(atIdx + 1)..];
|
||||
var beforeDigest = reference[..atIdx];
|
||||
var slashIdx = beforeDigest.IndexOf('/');
|
||||
registry = beforeDigest[..slashIdx];
|
||||
repository = beforeDigest[(slashIdx + 1)..];
|
||||
}
|
||||
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
|
||||
{
|
||||
// Has tag
|
||||
tag = reference[(colonIdx + 1)..];
|
||||
var beforeTag = reference[..colonIdx];
|
||||
var slashIdx = beforeTag.IndexOf('/');
|
||||
registry = beforeTag[..slashIdx];
|
||||
repository = beforeTag[(slashIdx + 1)..];
|
||||
registry = namePart[..slashIdx];
|
||||
repository = namePart[(slashIdx + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
repository = namePart;
|
||||
registry = defaultRegistry ?? string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repository) || string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// TrustVerdictService - Service for generating signed TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Services;
|
||||
@@ -266,6 +267,7 @@ public sealed record TrustVerdictResult
|
||||
public sealed class TrustVerdictService : ITrustVerdictService
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TrustVerdictService> _logger;
|
||||
|
||||
@@ -275,10 +277,12 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
public TrustVerdictService(
|
||||
IOptionsMonitor<TrustVerdictServiceOptions> options,
|
||||
ILogger<TrustVerdictService> logger,
|
||||
ITrustEvidenceMerkleBuilder merkleBuilder,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -441,7 +445,6 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
|
||||
// Build evidence chain
|
||||
var evidenceItems = request.EvidenceItems
|
||||
.OrderBy(e => e.Digest, StringComparer.Ordinal)
|
||||
.Select(e => new TrustEvidenceItem
|
||||
{
|
||||
Type = e.Type,
|
||||
@@ -452,12 +455,13 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = ComputeMerkleRoot(evidenceItems);
|
||||
var orderedEvidence = TrustEvidenceOrdering.OrderItems(evidenceItems).ToList();
|
||||
var merkleTree = _merkleBuilder.Build(orderedEvidence);
|
||||
|
||||
var evidence = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Items = evidenceItems
|
||||
MerkleRoot = merkleTree.Root,
|
||||
Items = orderedEvidence
|
||||
};
|
||||
|
||||
// Build metadata
|
||||
@@ -560,54 +564,17 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
|
||||
|
||||
// Reputation reason
|
||||
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
|
||||
var reputationPercent = reputation.Composite.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Issuer reputation: {reputationPercent} ({reputation.SampleCount} samples)");
|
||||
|
||||
// Composite summary
|
||||
var tier = TrustTiers.FromScore(compositeScore);
|
||||
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
|
||||
var compositePercent = compositeScore.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Overall trust: {tier} ({compositePercent})");
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
|
||||
}
|
||||
|
||||
// Get leaf hashes
|
||||
var hashes = items
|
||||
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
|
||||
.ToList();
|
||||
|
||||
// Build tree bottom-up
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var newLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
|
||||
hashes[i].CopyTo(combined, 0);
|
||||
hashes[i + 1].CopyTo(combined, hashes[i].Length);
|
||||
newLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, promote as-is
|
||||
newLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
hashes = newLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0067-M | DONE | Maintainability audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-T | DONE | Test coverage audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |
|
||||
|
||||
Reference in New Issue
Block a user