// ----------------------------------------------------------------------------- // ReplayHashService.cs // Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-023) // Task: Compute replay hash per lineage node // Description: Implements deterministic replay hash computation per spec: // Hash = SHA256(sbom_digest + feeds_snapshot_digest + // policy_version + vex_verdicts_digest + timestamp) // ----------------------------------------------------------------------------- using System.Diagnostics; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging; namespace StellaOps.SbomService.Services; /// /// Implementation of . /// Computes deterministic replay hashes for lineage nodes. /// internal sealed class ReplayHashService : IReplayHashService { private readonly IFeedsSnapshotService? _feedsService; private readonly IPolicyVersionService? _policyService; private readonly IVexVerdictsDigestService? _vexService; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly ActivitySource _activitySource = new("StellaOps.SbomService.ReplayHash"); // Delimiter used between hash components for determinism private const char Delimiter = '|'; // Timestamp format for reproducibility (minute precision) private const string TimestampFormat = "yyyy-MM-ddTHH:mm"; public ReplayHashService( ILogger logger, IFeedsSnapshotService? feedsService = null, IPolicyVersionService? policyService = null, IVexVerdictsDigestService? vexService = null, TimeProvider? timeProvider = null) { _logger = logger; _feedsService = feedsService; _policyService = policyService; _vexService = vexService; _timeProvider = timeProvider ?? TimeProvider.System; } /// public string ComputeHash(ReplayHashInputs inputs) { ArgumentNullException.ThrowIfNull(inputs); using var activity = _activitySource.StartActivity("ComputeReplayHash"); activity?.SetTag("sbom_digest", inputs.SbomDigest); // Build canonical string representation // Order: sbom_digest | feeds_snapshot_digest | policy_version | vex_verdicts_digest | timestamp var canonicalString = BuildCanonicalString(inputs); // Compute SHA256 hash var bytes = Encoding.UTF8.GetBytes(canonicalString); var hash = SHA256.HashData(bytes); var hexHash = Convert.ToHexStringLower(hash); activity?.SetTag("replay_hash", hexHash); _logger.LogDebug( "Computed replay hash {Hash} for SBOM {SbomDigest}", hexHash, inputs.SbomDigest); return hexHash; } /// public async Task ComputeHashAsync( string sbomDigest, string tenantId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest); ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); using var activity = _activitySource.StartActivity("ComputeReplayHashAsync"); activity?.SetTag("sbom_digest", sbomDigest); activity?.SetTag("tenant_id", tenantId); var now = _timeProvider.GetUtcNow(); // Gather inputs from services var feedsDigest = await GetFeedsSnapshotDigestAsync(tenantId, ct); var policyVersion = await GetPolicyVersionAsync(tenantId, ct); var vexDigest = await GetVexVerdictsDigestAsync(sbomDigest, tenantId, ct); var inputs = new ReplayHashInputs { SbomDigest = sbomDigest, FeedsSnapshotDigest = feedsDigest, PolicyVersion = policyVersion, VexVerdictsDigest = vexDigest, Timestamp = TruncateToMinute(now) }; var hash = ComputeHash(inputs); return new ReplayHashResult { Hash = hash, Inputs = inputs, ComputedAt = now }; } /// public bool VerifyHash(string expectedHash, ReplayHashInputs inputs) { ArgumentException.ThrowIfNullOrWhiteSpace(expectedHash); ArgumentNullException.ThrowIfNull(inputs); var computedHash = ComputeHash(inputs); var matches = string.Equals(expectedHash, computedHash, StringComparison.OrdinalIgnoreCase); if (!matches) { _logger.LogWarning( "Replay hash verification failed. Expected={Expected}, Computed={Computed}", expectedHash, computedHash); } return matches; } /// /// Builds the canonical string representation for hashing. /// Components are sorted and concatenated with delimiters for determinism. /// private static string BuildCanonicalString(ReplayHashInputs inputs) { var sb = new StringBuilder(512); // Component 1: SBOM digest (normalized to lowercase) sb.Append(NormalizeDigest(inputs.SbomDigest)); sb.Append(Delimiter); // Component 2: Feeds snapshot digest sb.Append(NormalizeDigest(inputs.FeedsSnapshotDigest)); sb.Append(Delimiter); // Component 3: Policy version (trimmed, lowercase for consistency) sb.Append(inputs.PolicyVersion.Trim().ToLowerInvariant()); sb.Append(Delimiter); // Component 4: VEX verdicts digest sb.Append(NormalizeDigest(inputs.VexVerdictsDigest)); sb.Append(Delimiter); // Component 5: Timestamp (minute precision, UTC) sb.Append(inputs.Timestamp.ToUniversalTime().ToString(TimestampFormat)); return sb.ToString(); } /// /// Normalizes a digest string for consistent hashing. /// private static string NormalizeDigest(string digest) { // Handle digest formats: sha256:xxxx, xxxx (bare) var normalized = digest.Trim().ToLowerInvariant(); // If it doesn't have algorithm prefix, assume sha256 if (!normalized.Contains(':')) { normalized = $"sha256:{normalized}"; } return normalized; } /// /// Truncates a timestamp to minute precision for reproducibility. /// private static DateTimeOffset TruncateToMinute(DateTimeOffset timestamp) { return new DateTimeOffset( timestamp.Year, timestamp.Month, timestamp.Day, timestamp.Hour, timestamp.Minute, 0, TimeSpan.Zero); } private async Task GetFeedsSnapshotDigestAsync(string tenantId, CancellationToken ct) { if (_feedsService is not null) { return await _feedsService.GetCurrentSnapshotDigestAsync(tenantId, ct); } // Fallback: return a placeholder indicating feeds service not available _logger.LogDebug("FeedsSnapshotService not available, using placeholder digest"); return "sha256:feeds-snapshot-unavailable"; } private async Task GetPolicyVersionAsync(string tenantId, CancellationToken ct) { if (_policyService is not null) { return await _policyService.GetCurrentVersionAsync(tenantId, ct); } // Fallback: return default policy version _logger.LogDebug("PolicyVersionService not available, using default version"); return "v1.0.0-default"; } private async Task GetVexVerdictsDigestAsync( string sbomDigest, string tenantId, CancellationToken ct) { if (_vexService is not null) { return await _vexService.ComputeVerdictsDigestAsync(sbomDigest, tenantId, ct); } // Fallback: return a placeholder _logger.LogDebug("VexVerdictsDigestService not available, using placeholder digest"); return "sha256:vex-verdicts-unavailable"; } } /// /// Service for getting the current feeds snapshot digest. /// internal interface IFeedsSnapshotService { /// /// Gets the content digest of the current vulnerability feeds snapshot. /// Task GetCurrentSnapshotDigestAsync(string tenantId, CancellationToken ct = default); } /// /// Service for getting the current policy version. /// internal interface IPolicyVersionService { /// /// Gets the current policy bundle version for a tenant. /// Task GetCurrentVersionAsync(string tenantId, CancellationToken ct = default); } /// /// Service for computing VEX verdicts digest. /// internal interface IVexVerdictsDigestService { /// /// Computes a content digest of all VEX verdicts applicable to an SBOM. /// Task ComputeVerdictsDigestAsync( string sbomDigest, string tenantId, CancellationToken ct = default); }