273 lines
9.0 KiB
C#
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);
|
|
}
|