Files
git.stella-ops.org/src/SbomService/StellaOps.SbomService/Services/ReplayHashService.cs

273 lines
9.0 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Implementation of <see cref="IReplayHashService"/>.
/// Computes deterministic replay hashes for lineage nodes.
/// </summary>
internal sealed class ReplayHashService : IReplayHashService
{
private readonly IFeedsSnapshotService? _feedsService;
private readonly IPolicyVersionService? _policyService;
private readonly IVexVerdictsDigestService? _vexService;
private readonly ILogger<ReplayHashService> _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<ReplayHashService> 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;
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public async Task<ReplayHashResult> 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
};
}
/// <inheritdoc/>
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;
}
/// <summary>
/// Builds the canonical string representation for hashing.
/// Components are sorted and concatenated with delimiters for determinism.
/// </summary>
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();
}
/// <summary>
/// Normalizes a digest string for consistent hashing.
/// </summary>
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;
}
/// <summary>
/// Truncates a timestamp to minute precision for reproducibility.
/// </summary>
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<string> 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<string> 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<string> 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";
}
}
/// <summary>
/// Service for getting the current feeds snapshot digest.
/// </summary>
internal interface IFeedsSnapshotService
{
/// <summary>
/// Gets the content digest of the current vulnerability feeds snapshot.
/// </summary>
Task<string> GetCurrentSnapshotDigestAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for getting the current policy version.
/// </summary>
internal interface IPolicyVersionService
{
/// <summary>
/// Gets the current policy bundle version for a tenant.
/// </summary>
Task<string> GetCurrentVersionAsync(string tenantId, CancellationToken ct = default);
}
/// <summary>
/// Service for computing VEX verdicts digest.
/// </summary>
internal interface IVexVerdictsDigestService
{
/// <summary>
/// Computes a content digest of all VEX verdicts applicable to an SBOM.
/// </summary>
Task<string> ComputeVerdictsDigestAsync(
string sbomDigest,
string tenantId,
CancellationToken ct = default);
}