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