402 lines
13 KiB
C#
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";
|
|
}
|
|
}
|