// ----------------------------------------------------------------------------- // AttestationChainVerifier.cs // Sprint: SPRINT_3801_0001_0003_chain_verification (CHAIN-003) // Description: Verifies attestation chain integrity. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Scanner.WebService.Services; /// /// Verifies attestation chain integrity. /// public sealed class AttestationChainVerifier : IAttestationChainVerifier { private readonly ILogger _logger; private readonly AttestationChainVerifierOptions _options; private readonly TimeProvider _timeProvider; private readonly IPolicyDecisionAttestationService _policyAttestationService; private readonly IRichGraphAttestationService _richGraphAttestationService; private readonly IHumanApprovalAttestationService _humanApprovalAttestationService; /// /// Initializes a new instance of . /// public AttestationChainVerifier( ILogger logger, IOptions options, TimeProvider timeProvider, IPolicyDecisionAttestationService policyAttestationService, IRichGraphAttestationService richGraphAttestationService, IHumanApprovalAttestationService humanApprovalAttestationService) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _policyAttestationService = policyAttestationService ?? throw new ArgumentNullException(nameof(policyAttestationService)); _richGraphAttestationService = richGraphAttestationService ?? throw new ArgumentNullException(nameof(richGraphAttestationService)); _humanApprovalAttestationService = humanApprovalAttestationService ?? throw new ArgumentNullException(nameof(humanApprovalAttestationService)); } /// public async Task VerifyChainAsync( ChainVerificationInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); if (string.IsNullOrWhiteSpace(input.FindingId)) { throw new ArgumentException("FindingId is required", nameof(input)); } if (string.IsNullOrWhiteSpace(input.RootDigest)) { throw new ArgumentException("RootDigest is required", nameof(input)); } _logger.LogDebug( "Verifying attestation chain for scan {ScanId}, finding {FindingId}", input.ScanId, input.FindingId); var stopwatch = Stopwatch.StartNew(); var details = new List(); var attestations = new List(); var now = _timeProvider.GetUtcNow(); var hasFailures = false; var hasExpired = false; // Collect attestations in chain order // 1. RichGraph attestation (reachability analysis) var richGraphResult = await VerifyRichGraphAttestationAsync( input.ScanId, input.FindingId, now, input.ExpirationGracePeriod, cancellationToken); if (richGraphResult.Detail != null) { details.Add(richGraphResult.Detail); } if (richGraphResult.Attestation != null) { attestations.Add(richGraphResult.Attestation); } hasFailures |= richGraphResult.IsFailed; hasExpired |= richGraphResult.IsExpired; // 2. Policy decision attestation var policyResult = await VerifyPolicyAttestationAsync( input.ScanId, input.FindingId, now, input.ExpirationGracePeriod, cancellationToken); if (policyResult.Detail != null) { details.Add(policyResult.Detail); } if (policyResult.Attestation != null) { attestations.Add(policyResult.Attestation); } hasFailures |= policyResult.IsFailed; hasExpired |= policyResult.IsExpired; // 3. Human approval attestation var humanApprovalResult = await VerifyHumanApprovalAttestationAsync( input.ScanId, input.FindingId, now, input.ExpirationGracePeriod, cancellationToken); if (humanApprovalResult.Detail != null) { details.Add(humanApprovalResult.Detail); } if (humanApprovalResult.Attestation != null) { attestations.Add(humanApprovalResult.Attestation); } hasFailures |= humanApprovalResult.IsFailed; hasExpired |= humanApprovalResult.IsExpired; stopwatch.Stop(); // Determine chain status var chainStatus = DetermineChainStatus( attestations, hasFailures, hasExpired, input.RequiredTypes, input.RequireHumanApproval); // Build the chain var chain = new AttestationChain { ChainId = ComputeChainId(input.ScanId, input.FindingId, input.RootDigest), ScanId = input.ScanId.ToString(), FindingId = input.FindingId, RootDigest = input.RootDigest, Attestations = attestations.ToImmutableList(), Verified = chainStatus == ChainStatus.Complete, VerifiedAt = now, Status = chainStatus, ExpiresAt = GetEarliestExpiration(attestations) }; _logger.LogInformation( "Chain verification completed in {ElapsedMs}ms: {Status} with {Count} attestations", stopwatch.ElapsedMilliseconds, chainStatus, attestations.Count); if (chainStatus == ChainStatus.Complete) { return ChainVerificationResult.Succeeded(chain, details); } var errorMessage = chainStatus switch { ChainStatus.Expired => "One or more attestations have expired", ChainStatus.Invalid => "Signature verification failed or attestation revoked", ChainStatus.Broken => "Chain link broken or digest mismatch", ChainStatus.Partial => "Required attestations are missing", ChainStatus.Empty => "No attestations found in chain", _ => "Chain verification failed" }; // Include details in failure result so callers can inspect why it failed return new ChainVerificationResult { Success = false, Chain = chain, Error = errorMessage, Details = details }; } /// public async Task GetChainAsync( ScanId scanId, string findingId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(findingId)) { return null; } var attestations = new List(); var now = _timeProvider.GetUtcNow(); // Collect attestations (without full verification) // Note: This is a simplified implementation; in production we'd have a more // efficient way to query attestations by finding ID // For now, we return null since we don't have a lookup by finding ID // The full implementation would query attestation stores _logger.LogDebug( "GetChainAsync called for scan {ScanId}, finding {FindingId}", scanId, findingId); // Placeholder: return null until we have proper attestation indexing await Task.CompletedTask; return null; } /// public bool IsChainComplete(AttestationChain chain, params AttestationType[] requiredTypes) { ArgumentNullException.ThrowIfNull(chain); if (requiredTypes.Length == 0) { return chain.Attestations.Count > 0; } var presentTypes = chain.Attestations .Where(a => a.Verified) .Select(a => a.Type) .ToHashSet(); return requiredTypes.All(t => presentTypes.Contains(t)); } /// public DateTimeOffset? GetEarliestExpiration(AttestationChain chain) { ArgumentNullException.ThrowIfNull(chain); return GetEarliestExpiration(chain.Attestations); } private static DateTimeOffset? GetEarliestExpiration(IEnumerable attestations) { var expirations = attestations .Where(a => a.Verified) .Select(a => a.ExpiresAt) .ToList(); return expirations.Count > 0 ? expirations.Min() : null; } private async Task VerifyRichGraphAttestationAsync( ScanId scanId, string findingId, DateTimeOffset now, TimeSpan gracePeriod, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); try { // Try to get the RichGraph attestation // Note: We use the finding ID as the graph ID for lookup // In practice, we'd have a mapping from finding to graph var attestation = await _richGraphAttestationService.GetAttestationAsync( scanId, findingId, cancellationToken); stopwatch.Stop(); if (attestation == null) { return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.RichGraph, AttestationId = "not-found", Status = AttestationVerificationStatus.NotFound, Verified = false, VerificationTime = stopwatch.Elapsed, Error = "RichGraph attestation not found" }, IsFailed = false, // Not found is partial, not failed IsExpired = false }; } var statement = attestation.Statement!; var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7); var isExpired = now > expiresAt.Add(gracePeriod); var chainAttestation = new ChainAttestation { Type = AttestationType.RichGraph, AttestationId = attestation.AttestationId!, CreatedAt = statement.Predicate.ComputedAt, ExpiresAt = expiresAt, Verified = !isExpired, VerificationStatus = isExpired ? AttestationVerificationStatus.Expired : AttestationVerificationStatus.Valid, SubjectDigest = statement.Predicate.GraphDigest, PredicateType = statement.PredicateType }; return new AttestationVerificationResult { Attestation = chainAttestation, Detail = new AttestationVerificationDetail { Type = AttestationType.RichGraph, AttestationId = attestation.AttestationId!, Status = chainAttestation.VerificationStatus, Verified = chainAttestation.Verified, VerificationTime = stopwatch.Elapsed }, IsFailed = false, IsExpired = isExpired }; } catch (Exception ex) { stopwatch.Stop(); _logger.LogWarning(ex, "Failed to verify RichGraph attestation for scan {ScanId}", scanId); return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.RichGraph, AttestationId = "error", Status = AttestationVerificationStatus.ChainBroken, Verified = false, VerificationTime = stopwatch.Elapsed, Error = ex.Message }, IsFailed = true, IsExpired = false }; } } private async Task VerifyPolicyAttestationAsync( ScanId scanId, string findingId, DateTimeOffset now, TimeSpan gracePeriod, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); try { // Try to get the policy attestation var attestation = await _policyAttestationService.GetAttestationAsync( scanId, findingId, cancellationToken); stopwatch.Stop(); if (attestation == null) { return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.PolicyDecision, AttestationId = "not-found", Status = AttestationVerificationStatus.NotFound, Verified = false, VerificationTime = stopwatch.Elapsed, Error = "Policy decision attestation not found" }, IsFailed = false, // Not found is partial, not failed IsExpired = false }; } var statement = attestation.Statement!; var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(7); var isExpired = now > expiresAt.Add(gracePeriod); var chainAttestation = new ChainAttestation { Type = AttestationType.PolicyDecision, AttestationId = attestation.AttestationId!, CreatedAt = statement.Predicate.EvaluatedAt, ExpiresAt = expiresAt, Verified = !isExpired, VerificationStatus = isExpired ? AttestationVerificationStatus.Expired : AttestationVerificationStatus.Valid, SubjectDigest = statement.Subject[0].Digest["sha256"], PredicateType = statement.PredicateType }; return new AttestationVerificationResult { Attestation = chainAttestation, Detail = new AttestationVerificationDetail { Type = AttestationType.PolicyDecision, AttestationId = attestation.AttestationId!, Status = chainAttestation.VerificationStatus, Verified = chainAttestation.Verified, VerificationTime = stopwatch.Elapsed }, IsFailed = false, IsExpired = isExpired }; } catch (Exception ex) { stopwatch.Stop(); _logger.LogWarning(ex, "Failed to verify policy attestation for scan {ScanId}", scanId); return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.PolicyDecision, AttestationId = "error", Status = AttestationVerificationStatus.ChainBroken, Verified = false, VerificationTime = stopwatch.Elapsed, Error = ex.Message }, IsFailed = true, IsExpired = false }; } } private async Task VerifyHumanApprovalAttestationAsync( ScanId scanId, string findingId, DateTimeOffset now, TimeSpan gracePeriod, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); try { // Try to get the human approval attestation var attestation = await _humanApprovalAttestationService.GetAttestationAsync( scanId, findingId, cancellationToken); stopwatch.Stop(); if (attestation == null) { return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.HumanApproval, AttestationId = "not-found", Status = AttestationVerificationStatus.NotFound, Verified = false, VerificationTime = stopwatch.Elapsed, Error = "Human approval attestation not found" }, IsFailed = false, // Not found is partial, not failed IsExpired = false }; } // Check if attestation was revoked if (attestation.IsRevoked) { return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.HumanApproval, AttestationId = attestation.AttestationId!, Status = AttestationVerificationStatus.Revoked, Verified = false, VerificationTime = stopwatch.Elapsed, Error = "Human approval attestation has been revoked" }, IsFailed = true, IsExpired = false }; } var statement = attestation.Statement!; // Default to 30 days (human approval default TTL) if not specified var expiresAt = statement.Predicate.ExpiresAt ?? now.AddDays(30); var isExpired = now > expiresAt.Add(gracePeriod); // Get subject digest if available var subjectDigest = statement.Subject.Count > 0 && statement.Subject[0].Digest.TryGetValue("sha256", out var digest) ? digest : string.Empty; var chainAttestation = new ChainAttestation { Type = AttestationType.HumanApproval, AttestationId = attestation.AttestationId!, CreatedAt = statement.Predicate.ApprovedAt, ExpiresAt = expiresAt, Verified = !isExpired, VerificationStatus = isExpired ? AttestationVerificationStatus.Expired : AttestationVerificationStatus.Valid, SubjectDigest = subjectDigest, PredicateType = statement.PredicateType }; return new AttestationVerificationResult { Attestation = chainAttestation, Detail = new AttestationVerificationDetail { Type = AttestationType.HumanApproval, AttestationId = attestation.AttestationId!, Status = chainAttestation.VerificationStatus, Verified = chainAttestation.Verified, VerificationTime = stopwatch.Elapsed }, IsFailed = false, IsExpired = isExpired }; } catch (Exception ex) { stopwatch.Stop(); _logger.LogWarning(ex, "Failed to verify human approval attestation for scan {ScanId}", scanId); return new AttestationVerificationResult { Detail = new AttestationVerificationDetail { Type = AttestationType.HumanApproval, AttestationId = "error", Status = AttestationVerificationStatus.ChainBroken, Verified = false, VerificationTime = stopwatch.Elapsed, Error = ex.Message }, IsFailed = true, IsExpired = false }; } } private static ChainStatus DetermineChainStatus( List attestations, bool hasFailures, bool hasExpired, IReadOnlyList? requiredTypes, bool requireHumanApproval) { if (hasFailures) { return ChainStatus.Invalid; } if (attestations.Count == 0) { return ChainStatus.Empty; } if (hasExpired) { return ChainStatus.Expired; } // Check for broken chain (digest mismatches would be detected during verification) if (attestations.Any(a => a.VerificationStatus == AttestationVerificationStatus.ChainBroken)) { return ChainStatus.Broken; } // Check for required types var presentTypes = attestations .Where(a => a.Verified) .Select(a => a.Type) .ToHashSet(); if (requiredTypes != null && requiredTypes.Count > 0) { if (!requiredTypes.All(t => presentTypes.Contains(t))) { return ChainStatus.Partial; } } if (requireHumanApproval && !presentTypes.Contains(AttestationType.HumanApproval)) { return ChainStatus.Partial; } // All verified attestations present return ChainStatus.Complete; } private static string ComputeChainId(ScanId scanId, string findingId, string rootDigest) { var input = $"{scanId}|{findingId}|{rootDigest}"; return ComputeSha256(input); } private static string ComputeSha256(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hash = SHA256.HashData(bytes); return $"sha256:{Convert.ToHexStringLower(hash)}"; } private sealed record AttestationVerificationResult { public ChainAttestation? Attestation { get; init; } public AttestationVerificationDetail? Detail { get; init; } public bool IsFailed { get; init; } public bool IsExpired { get; init; } } } /// /// Options for attestation chain verification. /// public sealed class AttestationChainVerifierOptions { /// /// Default grace period for expired attestations in minutes. /// public int DefaultGracePeriodMinutes { get; set; } = 60; /// /// Whether to require human approval for high-severity findings. /// public bool RequireHumanApprovalForHighSeverity { get; set; } = true; /// /// Maximum chain depth to verify. /// public int MaxChainDepth { get; set; } = 10; /// /// Whether to fail on missing attestations vs. reporting partial status. /// public bool FailOnMissingAttestations { get; set; } = false; }