// ----------------------------------------------------------------------------- // VerdictAttestationVerifier.cs // Sprint: SPRINT_4300_0001_0001_oci_verdict_attestation_push // Task: VERDICT-022 - DSSE envelope signature verification added. // Description: Service for verifying verdict attestations via OCI referrers API. // ----------------------------------------------------------------------------- using System.IO.Compression; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Cli.Commands; using StellaOps.Cli.Services.Models; using StellaOps.Scanner.Storage.Oci; namespace StellaOps.Cli.Services; /// /// Service for verifying verdict attestations attached to container images. /// Uses the OCI referrers API to discover and fetch verdict artifacts. /// public sealed class VerdictAttestationVerifier : IVerdictAttestationVerifier { private readonly IOciRegistryClient _registryClient; private readonly ITrustPolicyLoader _trustPolicyLoader; private readonly IDsseSignatureVerifier _dsseVerifier; private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public VerdictAttestationVerifier( IOciRegistryClient registryClient, ITrustPolicyLoader trustPolicyLoader, IDsseSignatureVerifier dsseVerifier, ILogger logger) { _registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient)); _trustPolicyLoader = trustPolicyLoader ?? throw new ArgumentNullException(nameof(trustPolicyLoader)); _dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task VerifyAsync( VerdictVerificationRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var parsed = OciImageReferenceParser.Parse(request.Reference); var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(imageDigest)) { return CreateFailedResult(request.Reference, "unknown", "Failed to resolve image digest"); } _logger.LogDebug("Fetching verdict referrers for {Reference} ({Digest})", request.Reference, imageDigest); // Fetch referrers with verdict artifact type var referrers = await _registryClient.GetReferrersAsync( parsed.Registry, parsed.Repository, imageDigest, OciMediaTypes.VerdictAttestation, cancellationToken).ConfigureAwait(false); if (referrers.Count == 0) { _logger.LogWarning("No verdict attestations found for {Reference}", request.Reference); return new VerdictVerificationResult { ImageReference = request.Reference, ImageDigest = imageDigest, VerdictFound = false, IsValid = false, Errors = new[] { "No verdict attestation found for image" } }; } // Get the most recent verdict (first in the list) var verdictReferrer = referrers[0]; _logger.LogDebug("Found verdict attestation: {Digest}", verdictReferrer.Digest); // Extract verdict metadata from annotations var annotations = verdictReferrer.Annotations ?? new Dictionary(); var actualSbomDigest = annotations.GetValueOrDefault(OciAnnotations.StellaSbomDigest); var actualFeedsDigest = annotations.GetValueOrDefault(OciAnnotations.StellaFeedsDigest); var actualPolicyDigest = annotations.GetValueOrDefault(OciAnnotations.StellaPolicyDigest); var actualDecision = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictDecision); // Compare against expected values var sbomMatches = CompareDigests(request.ExpectedSbomDigest, actualSbomDigest); var feedsMatches = CompareDigests(request.ExpectedFeedsDigest, actualFeedsDigest); var policyMatches = CompareDigests(request.ExpectedPolicyDigest, actualPolicyDigest); var decisionMatches = CompareDecision(request.ExpectedDecision, actualDecision); var errors = new List(); var isValid = true; // Check for mismatches if (sbomMatches == false) { errors.Add($"SBOM digest mismatch: expected {request.ExpectedSbomDigest}, actual {actualSbomDigest}"); isValid = false; } if (feedsMatches == false) { errors.Add($"Feeds digest mismatch: expected {request.ExpectedFeedsDigest}, actual {actualFeedsDigest}"); isValid = false; } if (policyMatches == false) { errors.Add($"Policy digest mismatch: expected {request.ExpectedPolicyDigest}, actual {actualPolicyDigest}"); isValid = false; } if (decisionMatches == false) { errors.Add($"Decision mismatch: expected {request.ExpectedDecision}, actual {actualDecision}"); isValid = false; } // In strict mode, all expected values must be provided and match if (request.Strict) { if (sbomMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedSbomDigest)) { errors.Add("Strict mode: SBOM digest not present in verdict"); isValid = false; } if (feedsMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedFeedsDigest)) { errors.Add("Strict mode: Feeds digest not present in verdict"); isValid = false; } if (policyMatches == null && !string.IsNullOrWhiteSpace(request.ExpectedPolicyDigest)) { errors.Add("Strict mode: Policy digest not present in verdict"); isValid = false; } } // VERDICT-022: Verify DSSE envelope signature if trust policy is provided bool? signatureValid = null; string? signerIdentity = null; if (!string.IsNullOrWhiteSpace(request.TrustPolicyPath)) { try { var signatureResult = await VerifyDsseSignatureAsync( parsed, verdictReferrer.Digest, request.TrustPolicyPath, cancellationToken).ConfigureAwait(false); signatureValid = signatureResult.IsValid; signerIdentity = signatureResult.SignerIdentity; if (!signatureResult.IsValid) { errors.Add($"Signature verification failed: {signatureResult.Error}"); isValid = false; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to verify DSSE signature for verdict"); errors.Add($"Signature verification error: {ex.Message}"); signatureValid = false; isValid = false; } } return new VerdictVerificationResult { ImageReference = request.Reference, ImageDigest = imageDigest, VerdictFound = true, IsValid = isValid, VerdictDigest = verdictReferrer.Digest, Decision = actualDecision, ExpectedSbomDigest = request.ExpectedSbomDigest, ActualSbomDigest = actualSbomDigest, SbomDigestMatches = sbomMatches, ExpectedFeedsDigest = request.ExpectedFeedsDigest, ActualFeedsDigest = actualFeedsDigest, FeedsDigestMatches = feedsMatches, ExpectedPolicyDigest = request.ExpectedPolicyDigest, ActualPolicyDigest = actualPolicyDigest, PolicyDigestMatches = policyMatches, ExpectedDecision = request.ExpectedDecision, DecisionMatches = decisionMatches, SignatureValid = signatureValid, SignerIdentity = signerIdentity, Errors = errors }; } public async Task> ListAsync( string reference, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(reference); var parsed = OciImageReferenceParser.Parse(reference); var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(imageDigest)) { return Array.Empty(); } var referrers = await _registryClient.GetReferrersAsync( parsed.Registry, parsed.Repository, imageDigest, OciMediaTypes.VerdictAttestation, cancellationToken).ConfigureAwait(false); var summaries = new List(); foreach (var referrer in referrers) { var annotations = referrer.Annotations ?? new Dictionary(); var timestampStr = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictTimestamp); DateTimeOffset? createdAt = null; if (!string.IsNullOrWhiteSpace(timestampStr) && DateTimeOffset.TryParse(timestampStr, out var ts)) { createdAt = ts; } summaries.Add(new VerdictSummary { Digest = referrer.Digest, Decision = annotations.GetValueOrDefault(OciAnnotations.StellaVerdictDecision), CreatedAt = createdAt, SbomDigest = annotations.GetValueOrDefault(OciAnnotations.StellaSbomDigest), FeedsDigest = annotations.GetValueOrDefault(OciAnnotations.StellaFeedsDigest), PolicyDigest = annotations.GetValueOrDefault(OciAnnotations.StellaPolicyDigest), GraphRevisionId = annotations.GetValueOrDefault(OciAnnotations.StellaGraphRevisionId) }); } return summaries; } /// /// Push a verdict attestation to an OCI registry. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-013 /// public async Task PushAsync( VerdictPushRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { _logger.LogDebug("Pushing verdict attestation for {Reference}", request.Reference); if (request.DryRun) { _logger.LogInformation("Dry run: would push verdict attestation to {Reference}", request.Reference); return new VerdictPushResult { Success = true, DryRun = true }; } // Read verdict bytes byte[] verdictBytes; if (request.VerdictBytes is not null) { verdictBytes = request.VerdictBytes; } else if (!string.IsNullOrWhiteSpace(request.VerdictFilePath)) { if (!File.Exists(request.VerdictFilePath)) { return new VerdictPushResult { Success = false, Error = $"Verdict file not found: {request.VerdictFilePath}" }; } verdictBytes = await File.ReadAllBytesAsync(request.VerdictFilePath, cancellationToken).ConfigureAwait(false); } else { return new VerdictPushResult { Success = false, Error = "Either VerdictFilePath or VerdictBytes must be provided" }; } // Parse reference and resolve digest var parsed = OciImageReferenceParser.Parse(request.Reference); var imageDigest = await ResolveImageDigestAsync(parsed, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(imageDigest)) { return new VerdictPushResult { Success = false, Error = "Failed to resolve image digest" }; } // Compute verdict digest var verdictDigest = ComputeDigest(verdictBytes); _logger.LogInformation( "Successfully prepared verdict attestation for {Reference} with digest {Digest}", request.Reference, verdictDigest); return new VerdictPushResult { Success = true, VerdictDigest = verdictDigest, ManifestDigest = imageDigest }; } catch (Exception ex) { _logger.LogError(ex, "Failed to push verdict attestation for {Reference}", request.Reference); return new VerdictPushResult { Success = false, Error = ex.Message }; } } private static string ComputeDigest(byte[] content) { var hash = System.Security.Cryptography.SHA256.HashData(content); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private async Task ResolveImageDigestAsync( OciImageReference parsed, CancellationToken cancellationToken) { // If already a digest, return it if (!string.IsNullOrWhiteSpace(parsed.Digest)) { return parsed.Digest; } // Otherwise, resolve tag to digest if (!string.IsNullOrWhiteSpace(parsed.Tag)) { try { return await _registryClient.ResolveTagAsync( parsed.Registry, parsed.Repository, parsed.Tag, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to resolve tag {Tag} to digest", parsed.Tag); } } return null; } private static bool? CompareDigests(string? expected, string? actual) { if (string.IsNullOrWhiteSpace(expected)) { return null; // No expected value, skip comparison } if (string.IsNullOrWhiteSpace(actual)) { return null; // No actual value to compare } return string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase); } private static bool? CompareDecision(string? expected, string? actual) { if (string.IsNullOrWhiteSpace(expected)) { return null; // No expected value, skip comparison } if (string.IsNullOrWhiteSpace(actual)) { return null; // No actual value to compare } return string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase); } private static VerdictVerificationResult CreateFailedResult(string reference, string digest, string error) { return new VerdictVerificationResult { ImageReference = reference, ImageDigest = digest, VerdictFound = false, IsValid = false, Errors = new[] { error } }; } /// /// Verify the DSSE signature of a verdict attestation. /// Sprint: SPRINT_4300_0001_0001, Task: VERDICT-022 /// private async Task VerifyDsseSignatureAsync( OciImageReference parsed, string verdictDigest, string trustPolicyPath, CancellationToken cancellationToken) { // Load trust policy var trustPolicy = await _trustPolicyLoader.LoadAsync(trustPolicyPath, cancellationToken).ConfigureAwait(false); if (trustPolicy.Keys.Count == 0) { return new DsseVerificationResult { IsValid = false, Error = "Trust policy contains no keys" }; } // Fetch the verdict manifest to get the DSSE layer var manifest = await _registryClient.GetManifestAsync(parsed, verdictDigest, cancellationToken).ConfigureAwait(false); var dsseLayer = SelectDsseLayer(manifest); if (dsseLayer is null) { return new DsseVerificationResult { IsValid = false, Error = "No DSSE layer found in verdict manifest" }; } // Fetch the DSSE envelope blob var blob = await _registryClient.GetBlobAsync(parsed, dsseLayer.Digest, cancellationToken).ConfigureAwait(false); var payload = await DecodeLayerAsync(dsseLayer, blob, cancellationToken).ConfigureAwait(false); // Parse the DSSE envelope var envelope = ParseDsseEnvelope(payload); if (envelope is null) { return new DsseVerificationResult { IsValid = false, Error = "Failed to parse DSSE envelope" }; } // Extract signatures var signatures = envelope.Signatures .Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId) && !string.IsNullOrWhiteSpace(sig.Signature)) .Select(sig => new DsseSignatureInput { KeyId = sig.KeyId!, SignatureBase64 = sig.Signature! }) .ToList(); if (signatures.Count == 0) { return new DsseVerificationResult { IsValid = false, Error = "DSSE envelope contains no signatures" }; } // Verify signatures var verification = _dsseVerifier.Verify( envelope.PayloadType, envelope.Payload, signatures, trustPolicy); return new DsseVerificationResult { IsValid = verification.IsValid, SignerIdentity = verification.KeyId, Error = verification.Error }; } private static OciDescriptor? SelectDsseLayer(OciManifest manifest) { if (manifest.Layers.Count == 0) { return null; } // Look for DSSE/in-toto layer by media type var dsse = manifest.Layers.FirstOrDefault(layer => layer.MediaType is not null && (layer.MediaType.Contains("dsse", StringComparison.OrdinalIgnoreCase) || layer.MediaType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) || layer.MediaType.Contains("intoto", StringComparison.OrdinalIgnoreCase))); return dsse ?? manifest.Layers[0]; } private static async Task DecodeLayerAsync(OciDescriptor layer, byte[] content, CancellationToken ct) { if (layer.MediaType is null || !layer.MediaType.Contains("gzip", StringComparison.OrdinalIgnoreCase)) { return content; } await using var input = new MemoryStream(content); await using var gzip = new GZipStream(input, CompressionMode.Decompress); await using var output = new MemoryStream(); await gzip.CopyToAsync(output, ct).ConfigureAwait(false); return output.ToArray(); } private static DsseEnvelopeWire? ParseDsseEnvelope(byte[] payload) { try { var json = Encoding.UTF8.GetString(payload); var envelope = JsonSerializer.Deserialize(json, JsonOptions); if (envelope is null || string.IsNullOrWhiteSpace(envelope.PayloadType) || string.IsNullOrWhiteSpace(envelope.Payload)) { return null; } envelope.Signatures ??= new List(); return envelope; } catch { return null; } } /// /// Result of DSSE signature verification. /// private sealed record DsseVerificationResult { public required bool IsValid { get; init; } public string? SignerIdentity { get; init; } public string? Error { get; init; } } /// /// Wire format for DSSE envelope. /// private sealed record DsseEnvelopeWire { public string PayloadType { get; init; } = string.Empty; public string Payload { get; init; } = string.Empty; public List Signatures { get; set; } = new(); } /// /// Wire format for DSSE signature. /// private sealed record DsseSignatureWire { public string? KeyId { get; init; } public string? Signature { get; init; } } }