Files
git.stella-ops.org/src/__Libraries/StellaOps.Evidence.Pack/EvidenceCardService.cs

402 lines
13 KiB
C#

// <copyright file="EvidenceCardService.cs" company="StellaOps">
// 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.
// </copyright>
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;
/// <summary>
/// Implementation of <see cref="IEvidenceCardService"/>.
/// </summary>
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<EvidenceCardService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EvidenceCardService"/> class.
/// </summary>
public EvidenceCardService(
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
ILogger<EvidenceCardService>? logger = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<EvidenceCardService>.Instance;
}
/// <inheritdoc/>
public Task<EvidenceCard> 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);
}
/// <inheritdoc/>
public Task<EvidenceCardExport> 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);
}
/// <inheritdoc/>
public Task<EvidenceCardVerificationResult> VerifyCardAsync(
EvidenceCard card,
EvidenceCardVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(card);
options ??= new EvidenceCardVerificationOptions();
var issues = new List<string>();
// 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<SbomComponent>.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<string> 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";
}
}