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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user