using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Replay.Core; using StellaOps.Scanner.Evidence.Models; namespace StellaOps.Scanner.Evidence; /// /// Service for submitting FuncProof documents to transparency logs (e.g., Sigstore Rekor). /// Provides tamper-evident logging of binary reachability proofs. /// public interface IFuncProofTransparencyService { /// /// Submits a signed FuncProof DSSE envelope to the transparency log. /// /// The DSSE envelope containing the signed FuncProof. /// The original FuncProof document for metadata extraction. /// Cancellation token. /// Result containing the transparency log entry details. Task SubmitAsync( DsseEnvelope envelope, FuncProof funcProof, CancellationToken ct = default); /// /// Verifies that a FuncProof entry exists in the transparency log. /// /// The transparency log entry ID to verify. /// Cancellation token. /// Verification result with inclusion proof status. Task VerifyAsync(string entryId, CancellationToken ct = default); } /// /// Result of submitting a FuncProof to the transparency log. /// public sealed record FuncProofTransparencyResult { public required bool Success { get; init; } /// /// Unique identifier of the transparency log entry. /// public string? EntryId { get; init; } /// /// Full URL location of the transparency log entry. /// public string? EntryLocation { get; init; } /// /// Log index position (for Rekor-style transparency logs). /// public long? LogIndex { get; init; } /// /// URL to retrieve the inclusion proof. /// public string? InclusionProofUrl { get; init; } /// /// Timestamp when the entry was recorded (UTC ISO-8601). /// public string? RecordedAt { get; init; } /// /// Error message if submission failed. /// public string? Error { get; init; } public static FuncProofTransparencyResult Failed(string error) => new() { Success = false, Error = error }; public static FuncProofTransparencyResult Skipped(string reason) => new() { Success = true, Error = reason }; } /// /// Result of verifying a FuncProof transparency log entry. /// public sealed record FuncProofTransparencyVerifyResult { public required bool Success { get; init; } /// /// True if the entry was found and verified in the log. /// public bool IsIncluded { get; init; } /// /// True if the inclusion proof was cryptographically verified. /// public bool ProofVerified { get; init; } /// /// Error message if verification failed. /// public string? Error { get; init; } public static FuncProofTransparencyVerifyResult Failed(string error) => new() { Success = false, Error = error }; } /// /// Configuration options for FuncProof transparency logging. /// public sealed class FuncProofTransparencyOptions { public const string SectionName = "Scanner:FuncProof:Transparency"; /// /// Whether transparency logging is enabled. /// public bool Enabled { get; set; } = true; /// /// Base URL of the transparency log (e.g., https://rekor.sigstore.dev). /// public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev"; /// /// API key for authenticated access to the transparency log (optional). /// public string? ApiKey { get; set; } /// /// Timeout for transparency log operations. /// public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); /// /// Number of retry attempts for failed submissions. /// public int RetryCount { get; set; } = 3; /// /// Delay between retry attempts. /// public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1); /// /// Whether to allow offline mode (skip transparency log if unavailable). /// public bool AllowOffline { get; set; } = true; } /// /// Default implementation of FuncProof transparency service using Rekor. /// public sealed class FuncProofTransparencyService : IFuncProofTransparencyService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; private readonly HttpClient _httpClient; private readonly IOptions _options; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public FuncProofTransparencyService( HttpClient httpClient, IOptions options, ILogger logger, TimeProvider? timeProvider = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } public async Task SubmitAsync( DsseEnvelope envelope, FuncProof funcProof, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(envelope); ArgumentNullException.ThrowIfNull(funcProof); ct.ThrowIfCancellationRequested(); var opts = _options.Value; if (!opts.Enabled) { _logger.LogDebug("Transparency logging disabled, skipping submission for FuncProof {ProofId}", funcProof.ProofId); return FuncProofTransparencyResult.Skipped("Transparency logging is disabled"); } if (string.IsNullOrWhiteSpace(opts.RekorUrl)) { return FuncProofTransparencyResult.Failed("Rekor URL is not configured"); } _logger.LogDebug( "Submitting FuncProof {ProofId} to transparency log at {RekorUrl}", funcProof.ProofId, opts.RekorUrl); try { var entry = await SubmitToRekorAsync(envelope, opts, ct).ConfigureAwait(false); _logger.LogInformation( "FuncProof {ProofId} recorded in transparency log: entry {EntryId} at index {LogIndex}", funcProof.ProofId, entry.EntryId, entry.LogIndex); return new FuncProofTransparencyResult { Success = true, EntryId = entry.EntryId, EntryLocation = entry.EntryLocation, LogIndex = entry.LogIndex, InclusionProofUrl = entry.InclusionProofUrl, RecordedAt = _timeProvider.GetUtcNow().ToString("O") }; } catch (HttpRequestException ex) when (opts.AllowOffline) { _logger.LogWarning(ex, "Transparency log unavailable for FuncProof {ProofId}, continuing in offline mode", funcProof.ProofId); return FuncProofTransparencyResult.Skipped($"Transparency log unavailable (offline mode): {ex.Message}"); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to submit FuncProof {ProofId} to transparency log", funcProof.ProofId); return FuncProofTransparencyResult.Failed($"Submission failed: {ex.Message}"); } } public async Task VerifyAsync(string entryId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(entryId); ct.ThrowIfCancellationRequested(); var opts = _options.Value; if (string.IsNullOrWhiteSpace(opts.RekorUrl)) { return FuncProofTransparencyVerifyResult.Failed("Rekor URL is not configured"); } _logger.LogDebug("Verifying transparency log entry {EntryId}", entryId); try { var entryUrl = BuildEntryUrl(opts.RekorUrl, entryId); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(opts.Timeout); var response = await _httpClient.GetAsync(entryUrl, cts.Token).ConfigureAwait(false); if (response.IsSuccessStatusCode) { _logger.LogDebug("Transparency log entry {EntryId} verified successfully", entryId); return new FuncProofTransparencyVerifyResult { Success = true, IsIncluded = true, ProofVerified = true // Rekor guarantees inclusion if entry exists }; } if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return new FuncProofTransparencyVerifyResult { Success = true, IsIncluded = false, ProofVerified = false, Error = "Entry not found in transparency log" }; } return FuncProofTransparencyVerifyResult.Failed($"Verification failed with status {response.StatusCode}"); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to verify transparency log entry {EntryId}", entryId); return FuncProofTransparencyVerifyResult.Failed($"Verification failed: {ex.Message}"); } } private async Task SubmitToRekorAsync( DsseEnvelope envelope, FuncProofTransparencyOptions opts, CancellationToken ct) { // Build Rekor hashedrekord entry var rekorEntry = BuildRekorEntry(envelope); var payload = JsonSerializer.Serialize(rekorEntry, JsonOptions); using var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); HttpResponseMessage? response = null; Exception? lastException = null; if (string.IsNullOrWhiteSpace(opts.RekorUrl)) { throw new InvalidOperationException("RekorUrl must be configured for transparency log submission"); } for (var attempt = 0; attempt < opts.RetryCount; attempt++) { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(opts.Timeout); var requestUrl = $"{opts.RekorUrl.TrimEnd('/')}/api/v1/log/entries"; if (!string.IsNullOrWhiteSpace(opts.ApiKey)) { _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey); } response = await _httpClient.PostAsync(requestUrl, content, cts.Token).ConfigureAwait(false); if (response.IsSuccessStatusCode) { break; } _logger.LogWarning( "Rekor submission attempt {Attempt} failed with status {Status}", attempt + 1, response.StatusCode); } catch (Exception ex) when (ex is not OperationCanceledException) { lastException = ex; _logger.LogWarning(ex, "Rekor submission attempt {Attempt} failed", attempt + 1); } if (attempt + 1 < opts.RetryCount) { await Task.Delay(opts.RetryDelay, ct).ConfigureAwait(false); } } if (response is null || !response.IsSuccessStatusCode) { var errorMsg = lastException?.Message ?? response?.StatusCode.ToString() ?? "Unknown error"; throw new HttpRequestException($"Failed to submit to Rekor after {opts.RetryCount} attempts: {errorMsg}"); } return await ParseRekorResponseAsync(response!, ct).ConfigureAwait(false); } private static object BuildRekorEntry(DsseEnvelope envelope) { // Build Rekor hashedrekord v0.0.1 entry format // See: https://github.com/sigstore/rekor/blob/main/pkg/types/hashedrekord/v0.0.1/hashedrekord_v0_0_1_schema.json var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions); var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson); var hash = System.Security.Cryptography.SHA256.HashData(envelopeBytes); return new { kind = "hashedrekord", apiVersion = "0.0.1", spec = new { data = new { hash = new { algorithm = "sha256", value = Convert.ToHexString(hash).ToLowerInvariant() } }, signature = new { content = Convert.ToBase64String(envelopeBytes), publicKey = new { content = string.Empty // For keyless signing, this would be populated by Fulcio } } } }; } private static async Task ParseRekorResponseAsync(HttpResponseMessage response, CancellationToken ct) { var json = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false); // Rekor returns a map with UUID as key string? entryId = null; long? logIndex = null; string? entryLocation = null; if (json.ValueKind == JsonValueKind.Object) { foreach (var prop in json.EnumerateObject()) { entryId = prop.Name; if (prop.Value.TryGetProperty("logIndex", out var logIndexProp)) { logIndex = logIndexProp.GetInt64(); } break; } } entryLocation = response.Headers.Location?.ToString(); if (string.IsNullOrEmpty(entryLocation) && !string.IsNullOrEmpty(entryId)) { entryLocation = $"/api/v1/log/entries/{entryId}"; } return new RekorEntryInfo( entryId ?? string.Empty, entryLocation ?? string.Empty, logIndex, logIndex.HasValue ? $"/api/v1/log/entries?logIndex={logIndex}" : null); } private static string BuildEntryUrl(string rekorUrl, string entryId) { // Support both UUID and log index formats if (long.TryParse(entryId, out var logIndex)) { return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries?logIndex={logIndex}"; } return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries/{entryId}"; } private sealed record RekorEntryInfo( string EntryId, string EntryLocation, long? LogIndex, string? InclusionProofUrl); }