doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,292 @@
// -----------------------------------------------------------------------------
// VerdictLedgerService.cs
// Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
// Task: VL-003 - Implement VerdictLedger service with chain validation
// Description: Service layer for verdict ledger with chain integrity validation
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Repositories;
namespace StellaOps.Attestor.Services;
/// <summary>
/// Service for managing the append-only verdict ledger with cryptographic chain validation.
/// </summary>
public interface IVerdictLedgerService
{
/// <summary>
/// Appends a new verdict to the ledger, computing the verdict hash and linking to previous entry.
/// </summary>
Task<VerdictLedgerEntry> AppendVerdictAsync(AppendVerdictRequest request, CancellationToken ct = default);
/// <summary>
/// Verifies the integrity of the entire hash chain for a tenant.
/// </summary>
Task<ChainVerificationResult> VerifyChainIntegrityAsync(Guid tenantId, CancellationToken ct = default);
/// <summary>
/// Gets entries in a hash range.
/// </summary>
Task<IReadOnlyList<VerdictLedgerEntry>> GetChainAsync(
Guid tenantId,
string fromHash,
string toHash,
CancellationToken ct = default);
/// <summary>
/// Gets the latest verdict for a specific bom-ref.
/// </summary>
Task<VerdictLedgerEntry?> GetLatestVerdictAsync(string bomRef, Guid tenantId, CancellationToken ct = default);
}
/// <summary>
/// Request to append a verdict.
/// </summary>
public sealed record AppendVerdictRequest
{
/// <summary>Package URL or container digest.</summary>
public required string BomRef { get; init; }
/// <summary>CycloneDX serial number.</summary>
public string? CycloneDxSerial { get; init; }
/// <summary>Decision.</summary>
public VerdictDecision Decision { get; init; }
/// <summary>Reason for decision.</summary>
public string? Reason { get; init; }
/// <summary>Policy bundle ID.</summary>
public required string PolicyBundleId { get; init; }
/// <summary>Policy bundle hash.</summary>
public required string PolicyBundleHash { get; init; }
/// <summary>Verifier image digest.</summary>
public required string VerifierImageDigest { get; init; }
/// <summary>Signer key ID.</summary>
public required string SignerKeyId { get; init; }
/// <summary>Tenant ID.</summary>
public Guid TenantId { get; init; }
}
/// <summary>
/// Result of chain verification.
/// </summary>
public sealed record ChainVerificationResult
{
/// <summary>Whether the chain is valid.</summary>
public bool IsValid { get; init; }
/// <summary>Number of entries verified.</summary>
public long EntriesVerified { get; init; }
/// <summary>First broken entry (if any).</summary>
public VerdictLedgerEntry? FirstBrokenEntry { get; init; }
/// <summary>Error message (if any).</summary>
public string? ErrorMessage { get; init; }
}
/// <summary>
/// Implementation of the verdict ledger service.
/// </summary>
public sealed class VerdictLedgerService : IVerdictLedgerService
{
private readonly IVerdictLedgerRepository _repository;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Creates a new verdict ledger service.
/// </summary>
public VerdictLedgerService(IVerdictLedgerRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
/// <inheritdoc />
public async Task<VerdictLedgerEntry> AppendVerdictAsync(AppendVerdictRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
// Get the latest entry to determine prev_hash
var latest = await _repository.GetLatestAsync(request.TenantId, ct);
var prevHash = latest?.VerdictHash;
var createdAt = DateTimeOffset.UtcNow;
// Compute verdict hash using canonical JSON
var verdictHash = ComputeVerdictHash(request, prevHash, createdAt);
var entry = new VerdictLedgerEntry
{
BomRef = request.BomRef,
CycloneDxSerial = request.CycloneDxSerial,
Decision = request.Decision,
Reason = request.Reason,
PolicyBundleId = request.PolicyBundleId,
PolicyBundleHash = request.PolicyBundleHash,
VerifierImageDigest = request.VerifierImageDigest,
SignerKeyId = request.SignerKeyId,
PrevHash = prevHash,
VerdictHash = verdictHash,
CreatedAt = createdAt,
TenantId = request.TenantId
};
return await _repository.AppendAsync(entry, ct);
}
/// <inheritdoc />
public async Task<ChainVerificationResult> VerifyChainIntegrityAsync(Guid tenantId, CancellationToken ct = default)
{
// Get the latest entry
var latest = await _repository.GetLatestAsync(tenantId, ct);
if (latest == null)
{
return new ChainVerificationResult
{
IsValid = true,
EntriesVerified = 0
};
}
// Walk backward through the chain
long entriesVerified = 0;
var current = latest;
VerdictLedgerEntry? previous = null;
while (current != null)
{
entriesVerified++;
// Recompute the hash and verify it matches
var recomputedHash = RecomputeVerdictHash(current);
if (recomputedHash != current.VerdictHash)
{
return new ChainVerificationResult
{
IsValid = false,
EntriesVerified = entriesVerified,
FirstBrokenEntry = current,
ErrorMessage = $"Hash mismatch: stored={current.VerdictHash}, computed={recomputedHash}"
};
}
// Verify chain linkage
if (previous != null && previous.PrevHash != current.VerdictHash)
{
return new ChainVerificationResult
{
IsValid = false,
EntriesVerified = entriesVerified,
FirstBrokenEntry = previous,
ErrorMessage = $"Chain break: entry {previous.LedgerId} points to {previous.PrevHash} but previous entry hash is {current.VerdictHash}"
};
}
// Move to previous entry
previous = current;
if (current.PrevHash != null)
{
current = await _repository.GetByHashAsync(current.PrevHash, ct);
}
else
{
current = null; // Reached genesis
}
}
return new ChainVerificationResult
{
IsValid = true,
EntriesVerified = entriesVerified
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<VerdictLedgerEntry>> GetChainAsync(
Guid tenantId,
string fromHash,
string toHash,
CancellationToken ct = default)
{
return await _repository.GetChainAsync(tenantId, fromHash, toHash, ct);
}
/// <inheritdoc />
public async Task<VerdictLedgerEntry?> GetLatestVerdictAsync(
string bomRef,
Guid tenantId,
CancellationToken ct = default)
{
var entries = await _repository.GetByBomRefAsync(bomRef, tenantId, ct);
return entries.LastOrDefault();
}
/// <summary>
/// Computes the verdict hash using canonical JSON serialization.
/// </summary>
private static string ComputeVerdictHash(AppendVerdictRequest request, string? prevHash, DateTimeOffset createdAt)
{
// Create canonical object with sorted keys
var canonical = new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["bomRef"] = request.BomRef,
["createdAt"] = createdAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
["cyclonedxSerial"] = request.CycloneDxSerial,
["decision"] = request.Decision.ToString().ToLowerInvariant(),
["policyBundleHash"] = request.PolicyBundleHash,
["policyBundleId"] = request.PolicyBundleId,
["prevHash"] = prevHash,
["reason"] = request.Reason,
["signerKeyid"] = request.SignerKeyId,
["verifierImageDigest"] = request.VerifierImageDigest
};
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Recomputes the verdict hash from a stored entry for verification.
/// </summary>
private static string RecomputeVerdictHash(VerdictLedgerEntry entry)
{
var canonical = new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["bomRef"] = entry.BomRef,
["createdAt"] = entry.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
["cyclonedxSerial"] = entry.CycloneDxSerial,
["decision"] = entry.Decision.ToString().ToLowerInvariant(),
["policyBundleHash"] = entry.PolicyBundleHash,
["policyBundleId"] = entry.PolicyBundleId,
["prevHash"] = entry.PrevHash,
["reason"] = entry.Reason,
["signerKeyid"] = entry.SignerKeyId,
["verifierImageDigest"] = entry.VerifierImageDigest
};
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}