// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // Sprint: SPRINT_20260112_004_LB_evidence_card_core (EVPCARD-LB-002) // Description: Service implementation for evidence card operations. // using System.Collections.Immutable; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using StellaOps.Determinism; using StellaOps.Evidence.Pack.Models; namespace StellaOps.Evidence.Pack; /// /// Implementation of . /// public sealed class EvidenceCardService : IEvidenceCardService { private static readonly JsonSerializerOptions IndentedOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.Default }; private static readonly JsonSerializerOptions CompactOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.Default }; private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public EvidenceCardService( TimeProvider? timeProvider = null, IGuidProvider? guidProvider = null, ILogger? logger = null) { _timeProvider = timeProvider ?? TimeProvider.System; _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; } /// public Task CreateCardAsync( EvidenceCardRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var cardId = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture); var now = _timeProvider.GetUtcNow(); // Create subject var subject = new EvidenceCardSubject { FindingId = request.FindingId, ArtifactDigest = request.ArtifactDigest, ComponentPurl = request.ComponentPurl }; // Create placeholder SBOM excerpt (real implementation would fetch from SBOM service) var sbomExcerpt = CreatePlaceholderSbomExcerpt(request); // Create placeholder DSSE envelope (real implementation would sign the payload) var envelope = CreatePlaceholderEnvelope(cardId, subject, now); // Create Rekor receipt metadata (optional, placeholder for now) RekorReceiptMetadata? rekorReceipt = null; if (request.IncludeRekorReceipt) { // In real implementation, this would be populated from actual Rekor submission _logger.LogDebug("Rekor receipt requested but not yet implemented; card will have null receipt"); } var card = new EvidenceCard { CardId = cardId, Subject = subject, SbomExcerpt = sbomExcerpt, Envelope = envelope, RekorReceipt = rekorReceipt, GeneratedAt = now, Tool = new EvidenceCardTool { Name = "StellaOps", Version = "1.0.0", Vendor = "StellaOps Inc" } }; _logger.LogInformation("Created evidence card {CardId} for finding {FindingId}", cardId, request.FindingId); return Task.FromResult(card); } /// public Task ExportCardAsync( EvidenceCard card, EvidenceCardExportFormat format, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(card); byte[] content; string contentType; switch (format) { case EvidenceCardExportFormat.Json: content = JsonSerializer.SerializeToUtf8Bytes(card, IndentedOptions); contentType = "application/json"; break; case EvidenceCardExportFormat.CompactJson: content = JsonSerializer.SerializeToUtf8Bytes(card, CompactOptions); contentType = "application/json"; break; case EvidenceCardExportFormat.CanonicalJson: var json = JsonSerializer.Serialize(card, CompactOptions); content = Encoding.UTF8.GetBytes(CanonicalizeJson(json)); contentType = "application/json"; break; default: throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported export format"); } var digest = ComputeDigest(content); var export = new EvidenceCardExport { CardId = card.CardId, Format = format, Content = content, ContentDigest = digest, ContentType = contentType, FileName = $"evidence-card-{card.CardId}.json" }; _logger.LogDebug("Exported evidence card {CardId} to {Format} ({Size} bytes)", card.CardId, format, content.Length); return Task.FromResult(export); } /// public Task VerifyCardAsync( EvidenceCard card, EvidenceCardVerificationOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(card); options ??= new EvidenceCardVerificationOptions(); var issues = new List(); // Verify DSSE envelope (placeholder - real implementation would verify signature) var signatureValid = !string.IsNullOrEmpty(card.Envelope.PayloadDigest); if (!signatureValid) { issues.Add("DSSE envelope signature verification failed"); } // Verify SBOM digest var sbomDigestValid = !string.IsNullOrEmpty(card.SbomExcerpt.SbomDigest); if (!sbomDigestValid) { issues.Add("SBOM excerpt digest is missing"); } // Verify Rekor receipt if present bool? rekorReceiptValid = null; if (card.RekorReceipt is not null) { rekorReceiptValid = VerifyRekorReceiptOffline(card.RekorReceipt, options, issues); } else if (!options.AllowMissingReceipt) { issues.Add("Rekor receipt is required but not present"); } var valid = signatureValid && sbomDigestValid && (rekorReceiptValid ?? true) && issues.Count == 0; return Task.FromResult(new EvidenceCardVerificationResult { Valid = valid, SignatureValid = signatureValid, RekorReceiptValid = rekorReceiptValid, SbomDigestValid = sbomDigestValid, Issues = issues }); } private static SbomExcerpt CreatePlaceholderSbomExcerpt(EvidenceCardRequest request) { var components = ImmutableArray.Empty; if (!string.IsNullOrEmpty(request.ComponentPurl)) { components = ImmutableArray.Create(new SbomComponent { Purl = request.ComponentPurl, Name = ExtractNameFromPurl(request.ComponentPurl), Version = ExtractVersionFromPurl(request.ComponentPurl) }); } return new SbomExcerpt { Format = "cyclonedx", FormatVersion = "1.6", SbomDigest = $"sha256:{ComputeDigestString(request.ArtifactDigest)}", Components = components, MaxSizeBytes = request.MaxSbomExcerptSize }; } private static DsseEnvelope CreatePlaceholderEnvelope( string cardId, EvidenceCardSubject subject, DateTimeOffset timestamp) { var payload = JsonSerializer.Serialize(new { cardId, subject.FindingId, subject.ArtifactDigest, timestamp = timestamp.ToString("O", CultureInfo.InvariantCulture) }, CompactOptions); var payloadBytes = Encoding.UTF8.GetBytes(payload); var payloadBase64 = Convert.ToBase64String(payloadBytes); var payloadDigest = ComputeDigest(payloadBytes); return new DsseEnvelope { PayloadType = "application/vnd.stellaops.evidence-card+json", Payload = payloadBase64, PayloadDigest = payloadDigest, Signatures = ImmutableArray.Create(new DsseSignature { KeyId = "placeholder-key", Sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("placeholder-signature")) }) }; } private static bool VerifyRekorReceiptOffline( RekorReceiptMetadata receipt, EvidenceCardVerificationOptions options, List issues) { // Basic structural validation if (string.IsNullOrEmpty(receipt.Uuid)) { issues.Add("Rekor receipt UUID is missing"); return false; } if (receipt.LogIndex < 0) { issues.Add("Rekor receipt log index is invalid"); return false; } if (string.IsNullOrEmpty(receipt.RootHash)) { issues.Add("Rekor receipt root hash is missing"); return false; } if (receipt.InclusionProofHashes.Length == 0) { issues.Add("Rekor receipt inclusion proof is empty"); return false; } // Full verification would validate: // 1. Checkpoint signature against trusted keys // 2. Inclusion proof verification // 3. Entry body hash against log entry return true; } private static string CanonicalizeJson(string json) { // RFC 8785 canonicalization (simplified - real impl would use StellaOps.Canonical.Json) using var document = JsonDocument.Parse(json); using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false }); WriteCanonical(writer, document.RootElement); writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()); } private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element) { switch (element.ValueKind) { case JsonValueKind.Object: writer.WriteStartObject(); var properties = element.EnumerateObject() .OrderBy(p => p.Name, StringComparer.Ordinal) .ToList(); foreach (var prop in properties) { writer.WritePropertyName(prop.Name); WriteCanonical(writer, prop.Value); } writer.WriteEndObject(); break; case JsonValueKind.Array: writer.WriteStartArray(); foreach (var item in element.EnumerateArray()) { WriteCanonical(writer, item); } writer.WriteEndArray(); break; case JsonValueKind.String: writer.WriteStringValue(element.GetString()); break; case JsonValueKind.Number: if (element.TryGetInt64(out var longVal)) { writer.WriteNumberValue(longVal); } else { writer.WriteNumberValue(element.GetDouble()); } break; case JsonValueKind.True: writer.WriteBooleanValue(true); break; case JsonValueKind.False: writer.WriteBooleanValue(false); break; case JsonValueKind.Null: writer.WriteNullValue(); break; } } private static string ComputeDigest(byte[] data) { var hash = SHA256.HashData(data); return $"sha256:{Convert.ToHexStringLower(hash)}"; } private static string ComputeDigestString(string data) { var bytes = Encoding.UTF8.GetBytes(data); var hash = SHA256.HashData(bytes); return Convert.ToHexStringLower(hash); } private static string ExtractNameFromPurl(string purl) { // Simple PURL name extraction var parts = purl.Split('/'); if (parts.Length > 1) { var nameVersion = parts[^1]; var atIndex = nameVersion.IndexOf('@'); return atIndex > 0 ? nameVersion[..atIndex] : nameVersion; } return purl; } private static string ExtractVersionFromPurl(string purl) { var atIndex = purl.LastIndexOf('@'); return atIndex > 0 ? purl[(atIndex + 1)..] : "unknown"; } }