// -----------------------------------------------------------------------------
// 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;
}