Resolve Concelier/Excititor merge conflicts
This commit is contained in:
		@@ -0,0 +1,3 @@
 | 
			
		||||
using System.Runtime.CompilerServices;
 | 
			
		||||
 | 
			
		||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
 | 
			
		||||
@@ -0,0 +1,157 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Net.Http;
 | 
			
		||||
using System.Net.Http.Json;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
 | 
			
		||||
 | 
			
		||||
internal sealed class HttpRekorClient : IRekorClient
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
 | 
			
		||||
 | 
			
		||||
    private readonly HttpClient _httpClient;
 | 
			
		||||
    private readonly ILogger<HttpRekorClient> _logger;
 | 
			
		||||
 | 
			
		||||
    public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _httpClient = httpClient;
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
 | 
			
		||||
 | 
			
		||||
        using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
 | 
			
		||||
        {
 | 
			
		||||
            Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (response.StatusCode == HttpStatusCode.Conflict)
 | 
			
		||||
        {
 | 
			
		||||
            var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException($"Rekor reported a conflict: {message}");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        response.EnsureSuccessStatusCode();
 | 
			
		||||
 | 
			
		||||
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var root = document.RootElement;
 | 
			
		||||
 | 
			
		||||
        long? index = null;
 | 
			
		||||
        if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
 | 
			
		||||
        {
 | 
			
		||||
            index = indexValue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new RekorSubmissionResponse
 | 
			
		||||
        {
 | 
			
		||||
            Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
 | 
			
		||||
            Index = index,
 | 
			
		||||
            LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
 | 
			
		||||
            Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
 | 
			
		||||
            Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
 | 
			
		||||
 | 
			
		||||
        using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
 | 
			
		||||
        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (response.StatusCode == HttpStatusCode.NotFound)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug("Rekor proof for {Uuid} not found", rekorUuid);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        response.EnsureSuccessStatusCode();
 | 
			
		||||
 | 
			
		||||
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return TryParseProof(document.RootElement);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
 | 
			
		||||
    {
 | 
			
		||||
        var signatures = new List<object>();
 | 
			
		||||
        foreach (var sig in request.Bundle.Dsse.Signatures)
 | 
			
		||||
        {
 | 
			
		||||
            signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new
 | 
			
		||||
        {
 | 
			
		||||
            entries = new[]
 | 
			
		||||
            {
 | 
			
		||||
                new
 | 
			
		||||
                {
 | 
			
		||||
                    dsseEnvelope = new
 | 
			
		||||
                    {
 | 
			
		||||
                        payload = request.Bundle.Dsse.PayloadBase64,
 | 
			
		||||
                        payloadType = request.Bundle.Dsse.PayloadType,
 | 
			
		||||
                        signatures
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static RekorProofResponse? TryParseProof(JsonElement proofElement)
 | 
			
		||||
    {
 | 
			
		||||
        if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
 | 
			
		||||
        var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
 | 
			
		||||
 | 
			
		||||
        return new RekorProofResponse
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
 | 
			
		||||
                ? new RekorProofResponse.RekorCheckpoint
 | 
			
		||||
                {
 | 
			
		||||
                    Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
 | 
			
		||||
                    Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
 | 
			
		||||
                    RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
 | 
			
		||||
                    Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
 | 
			
		||||
                }
 | 
			
		||||
                : null,
 | 
			
		||||
            Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
 | 
			
		||||
                ? new RekorProofResponse.RekorInclusionProof
 | 
			
		||||
                {
 | 
			
		||||
                    LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
 | 
			
		||||
                    Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
 | 
			
		||||
                        ? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
 | 
			
		||||
                        : Array.Empty<string>()
 | 
			
		||||
                }
 | 
			
		||||
                : null
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Uri BuildUri(Uri baseUri, string relative)
 | 
			
		||||
    {
 | 
			
		||||
        if (!relative.StartsWith("/", StringComparison.Ordinal))
 | 
			
		||||
        {
 | 
			
		||||
            relative = "/" + relative;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new Uri(baseUri, relative);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,71 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
 | 
			
		||||
 | 
			
		||||
internal sealed class StubRekorClient : IRekorClient
 | 
			
		||||
{
 | 
			
		||||
    private readonly ILogger<StubRekorClient> _logger;
 | 
			
		||||
 | 
			
		||||
    public StubRekorClient(ILogger<StubRekorClient> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var uuid = Guid.NewGuid().ToString();
 | 
			
		||||
        _logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
 | 
			
		||||
 | 
			
		||||
        var proof = new RekorProofResponse
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = new RekorProofResponse.RekorCheckpoint
 | 
			
		||||
            {
 | 
			
		||||
                Origin = backend.Url.Host,
 | 
			
		||||
                Size = 1,
 | 
			
		||||
                RootHash = request.Meta.BundleSha256,
 | 
			
		||||
                Timestamp = DateTimeOffset.UtcNow
 | 
			
		||||
            },
 | 
			
		||||
            Inclusion = new RekorProofResponse.RekorInclusionProof
 | 
			
		||||
            {
 | 
			
		||||
                LeafHash = request.Meta.BundleSha256,
 | 
			
		||||
                Path = Array.Empty<string>()
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var response = new RekorSubmissionResponse
 | 
			
		||||
        {
 | 
			
		||||
            Uuid = uuid,
 | 
			
		||||
            Index = Random.Shared.NextInt64(1, long.MaxValue),
 | 
			
		||||
            LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
 | 
			
		||||
            Status = "included",
 | 
			
		||||
            Proof = proof
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return Task.FromResult(response);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        _logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
 | 
			
		||||
        return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = new RekorProofResponse.RekorCheckpoint
 | 
			
		||||
            {
 | 
			
		||||
                Origin = backend.Url.Host,
 | 
			
		||||
                Size = 1,
 | 
			
		||||
                RootHash = string.Empty,
 | 
			
		||||
                Timestamp = DateTimeOffset.UtcNow
 | 
			
		||||
            },
 | 
			
		||||
            Inclusion = new RekorProofResponse.RekorInclusionProof
 | 
			
		||||
            {
 | 
			
		||||
                LeafHash = string.Empty,
 | 
			
		||||
                Path = Array.Empty<string>()
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,123 @@
 | 
			
		||||
using System;
 | 
			
		||||
using Amazon.Runtime;
 | 
			
		||||
using Amazon.S3;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
using StellaOps.Attestor.Core.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Observability;
 | 
			
		||||
using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
using StellaOps.Attestor.Infrastructure.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
using StellaOps.Attestor.Infrastructure.Submission;
 | 
			
		||||
using StellaOps.Attestor.Core.Verification;
 | 
			
		||||
using StellaOps.Attestor.Infrastructure.Verification;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure;
 | 
			
		||||
 | 
			
		||||
public static class ServiceCollectionExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
 | 
			
		||||
    {
 | 
			
		||||
        services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
 | 
			
		||||
        services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
 | 
			
		||||
        });
 | 
			
		||||
        services.AddSingleton<AttestorMetrics>();
 | 
			
		||||
        services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
 | 
			
		||||
        services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
 | 
			
		||||
        services.AddHttpClient<HttpRekorClient>(client =>
 | 
			
		||||
        {
 | 
			
		||||
            client.Timeout = TimeSpan.FromSeconds(30);
 | 
			
		||||
        });
 | 
			
		||||
        services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IMongoClient>(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(options.Mongo.Uri))
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Attestor MongoDB connection string is not configured.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new MongoClient(options.Mongo.Uri);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            var client = sp.GetRequiredService<IMongoClient>();
 | 
			
		||||
            var databaseName = MongoUrl.Create(opts.Mongo.Uri).DatabaseName ?? opts.Mongo.Database;
 | 
			
		||||
            return client.GetDatabase(databaseName);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<MongoAttestorEntryRepository.AttestorEntryDocument>(opts.Mongo.EntriesCollection);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            return database.GetCollection<MongoAttestorAuditSink.AttestorAuditDocument>(opts.Mongo.AuditCollection);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IAttestorEntryRepository, MongoAttestorEntryRepository>();
 | 
			
		||||
        services.AddSingleton<IAttestorAuditSink, MongoAttestorAuditSink>();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IAttestorDedupeStore>(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(options.Redis.Url))
 | 
			
		||||
            {
 | 
			
		||||
                return new InMemoryAttestorDedupeStore();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
 | 
			
		||||
            return new RedisAttestorDedupeStore(multiplexer, sp.GetRequiredService<IOptions<AttestorOptions>>());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IConnectionMultiplexer>(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(options.Redis.Url))
 | 
			
		||||
            {
 | 
			
		||||
                throw new InvalidOperationException("Redis connection string is required when redis dedupe is enabled.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return ConnectionMultiplexer.Connect(options.Redis.Url);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        services.AddSingleton<IAttestorArchiveStore>(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
 | 
			
		||||
            if (options.S3.Enabled && !string.IsNullOrWhiteSpace(options.S3.Endpoint) && !string.IsNullOrWhiteSpace(options.S3.Bucket))
 | 
			
		||||
            {
 | 
			
		||||
                var config = new AmazonS3Config
 | 
			
		||||
                {
 | 
			
		||||
                    ServiceURL = options.S3.Endpoint,
 | 
			
		||||
                    ForcePathStyle = true,
 | 
			
		||||
                    UseHttp = !options.S3.UseTls
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                var client = new AmazonS3Client(FallbackCredentialsFactory.GetCredentials(), config);
 | 
			
		||||
                return new S3AttestorArchiveStore(client, sp.GetRequiredService<IOptions<AttestorOptions>>(), sp.GetRequiredService<ILogger<S3AttestorArchiveStore>>());
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new NullAttestorArchiveStore(sp.GetRequiredService<ILogger<NullAttestorArchiveStore>>());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return services;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,21 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <LangVersion>preview</LangVersion>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="MongoDB.Driver" Version="3.5.0" />
 | 
			
		||||
    <PackageReference Include="StackExchange.Redis" Version="2.8.24" />
 | 
			
		||||
    <PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -0,0 +1,33 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Concurrent;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
 | 
			
		||||
 | 
			
		||||
    public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        if (_store.TryGetValue(bundleSha256, out var entry))
 | 
			
		||||
        {
 | 
			
		||||
            if (entry.ExpiresAt > DateTimeOffset.UtcNow)
 | 
			
		||||
            {
 | 
			
		||||
                return Task.FromResult<string?>(entry.Uuid);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            _store.TryRemove(bundleSha256, out _);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Task.FromResult<string?>(null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        _store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
 | 
			
		||||
        return Task.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,115 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Attestor.Core.Audit;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class MongoAttestorAuditSink : IAttestorAuditSink
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AttestorAuditDocument> _collection;
 | 
			
		||||
 | 
			
		||||
    public MongoAttestorAuditSink(IMongoCollection<AttestorAuditDocument> collection)
 | 
			
		||||
    {
 | 
			
		||||
        _collection = collection;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var document = AttestorAuditDocument.FromRecord(record);
 | 
			
		||||
        return _collection.InsertOneAsync(document, cancellationToken: cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    internal sealed class AttestorAuditDocument
 | 
			
		||||
    {
 | 
			
		||||
        [BsonId]
 | 
			
		||||
        public ObjectId Id { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("ts")]
 | 
			
		||||
        public BsonDateTime Timestamp { get; set; } = BsonDateTime.Create(DateTime.UtcNow);
 | 
			
		||||
 | 
			
		||||
        [BsonElement("action")]
 | 
			
		||||
        public string Action { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("result")]
 | 
			
		||||
        public string Result { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("rekorUuid")]
 | 
			
		||||
        public string? RekorUuid { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("index")]
 | 
			
		||||
        public long? Index { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("artifactSha256")]
 | 
			
		||||
        public string ArtifactSha256 { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("bundleSha256")]
 | 
			
		||||
        public string BundleSha256 { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("backend")]
 | 
			
		||||
        public string Backend { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("latencyMs")]
 | 
			
		||||
        public long LatencyMs { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("caller")]
 | 
			
		||||
        public CallerDocument Caller { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [BsonElement("metadata")]
 | 
			
		||||
        public BsonDocument Metadata { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        public static AttestorAuditDocument FromRecord(AttestorAuditRecord record)
 | 
			
		||||
        {
 | 
			
		||||
            var metadata = new BsonDocument();
 | 
			
		||||
            foreach (var kvp in record.Metadata)
 | 
			
		||||
            {
 | 
			
		||||
                metadata[kvp.Key] = kvp.Value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new AttestorAuditDocument
 | 
			
		||||
            {
 | 
			
		||||
                Id = ObjectId.GenerateNewId(),
 | 
			
		||||
                Timestamp = BsonDateTime.Create(record.Timestamp.UtcDateTime),
 | 
			
		||||
                Action = record.Action,
 | 
			
		||||
                Result = record.Result,
 | 
			
		||||
                RekorUuid = record.RekorUuid,
 | 
			
		||||
                Index = record.Index,
 | 
			
		||||
                ArtifactSha256 = record.ArtifactSha256,
 | 
			
		||||
                BundleSha256 = record.BundleSha256,
 | 
			
		||||
                Backend = record.Backend,
 | 
			
		||||
                LatencyMs = record.LatencyMs,
 | 
			
		||||
                Caller = new CallerDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Subject = record.Caller.Subject,
 | 
			
		||||
                    Audience = record.Caller.Audience,
 | 
			
		||||
                    ClientId = record.Caller.ClientId,
 | 
			
		||||
                    MtlsThumbprint = record.Caller.MtlsThumbprint,
 | 
			
		||||
                    Tenant = record.Caller.Tenant
 | 
			
		||||
                },
 | 
			
		||||
                Metadata = metadata
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class CallerDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("subject")]
 | 
			
		||||
            public string? Subject { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("audience")]
 | 
			
		||||
            public string? Audience { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("clientId")]
 | 
			
		||||
            public string? ClientId { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("mtlsThumbprint")]
 | 
			
		||||
            public string? MtlsThumbprint { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("tenant")]
 | 
			
		||||
            public string? Tenant { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,342 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<AttestorEntryDocument> _entries;
 | 
			
		||||
 | 
			
		||||
    public MongoAttestorEntryRepository(IMongoCollection<AttestorEntryDocument> entries)
 | 
			
		||||
    {
 | 
			
		||||
        _entries = entries;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.BundleSha256, bundleSha256);
 | 
			
		||||
        var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return document?.ToDomain();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, rekorUuid);
 | 
			
		||||
        var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return document?.ToDomain();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Artifact.Sha256, artifactSha256);
 | 
			
		||||
        var documents = await _entries.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return documents.ConvertAll(static doc => doc.ToDomain());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var document = AttestorEntryDocument.FromDomain(entry);
 | 
			
		||||
        var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, document.Id);
 | 
			
		||||
        await _entries.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [BsonIgnoreExtraElements]
 | 
			
		||||
    internal sealed class AttestorEntryDocument
 | 
			
		||||
    {
 | 
			
		||||
        [BsonId]
 | 
			
		||||
        public string Id { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("artifact")]
 | 
			
		||||
        public ArtifactDocument Artifact { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [BsonElement("bundleSha256")]
 | 
			
		||||
        public string BundleSha256 { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        [BsonElement("index")]
 | 
			
		||||
        public long? Index { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("proof")]
 | 
			
		||||
        public ProofDocument? Proof { get; set; }
 | 
			
		||||
 | 
			
		||||
        [BsonElement("log")]
 | 
			
		||||
        public LogDocument Log { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [BsonElement("createdAt")]
 | 
			
		||||
        public BsonDateTime CreatedAt { get; set; } = BsonDateTime.Create(System.DateTimeOffset.UtcNow);
 | 
			
		||||
 | 
			
		||||
        [BsonElement("status")]
 | 
			
		||||
        public string Status { get; set; } = "pending";
 | 
			
		||||
 | 
			
		||||
        [BsonElement("signerIdentity")]
 | 
			
		||||
        public SignerIdentityDocument SignerIdentity { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [BsonElement("mirror")]
 | 
			
		||||
        public MirrorDocument? Mirror { get; set; }
 | 
			
		||||
 | 
			
		||||
        public static AttestorEntryDocument FromDomain(AttestorEntry entry)
 | 
			
		||||
        {
 | 
			
		||||
            return new AttestorEntryDocument
 | 
			
		||||
            {
 | 
			
		||||
                Id = entry.RekorUuid,
 | 
			
		||||
                Artifact = new ArtifactDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Sha256 = entry.Artifact.Sha256,
 | 
			
		||||
                    Kind = entry.Artifact.Kind,
 | 
			
		||||
                    ImageDigest = entry.Artifact.ImageDigest,
 | 
			
		||||
                    SubjectUri = entry.Artifact.SubjectUri
 | 
			
		||||
                },
 | 
			
		||||
                BundleSha256 = entry.BundleSha256,
 | 
			
		||||
                Index = entry.Index,
 | 
			
		||||
                Proof = entry.Proof is null ? null : new ProofDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Checkpoint = entry.Proof.Checkpoint is null ? null : new CheckpointDocument
 | 
			
		||||
                    {
 | 
			
		||||
                        Origin = entry.Proof.Checkpoint.Origin,
 | 
			
		||||
                        Size = entry.Proof.Checkpoint.Size,
 | 
			
		||||
                        RootHash = entry.Proof.Checkpoint.RootHash,
 | 
			
		||||
                        Timestamp = entry.Proof.Checkpoint.Timestamp is null
 | 
			
		||||
                            ? null
 | 
			
		||||
                            : BsonDateTime.Create(entry.Proof.Checkpoint.Timestamp.Value)
 | 
			
		||||
                    },
 | 
			
		||||
                    Inclusion = entry.Proof.Inclusion is null ? null : new InclusionDocument
 | 
			
		||||
                    {
 | 
			
		||||
                        LeafHash = entry.Proof.Inclusion.LeafHash,
 | 
			
		||||
                        Path = entry.Proof.Inclusion.Path
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                Log = new LogDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = entry.Log.Backend,
 | 
			
		||||
                    Url = entry.Log.Url,
 | 
			
		||||
                    LogId = entry.Log.LogId
 | 
			
		||||
                },
 | 
			
		||||
                CreatedAt = BsonDateTime.Create(entry.CreatedAt.UtcDateTime),
 | 
			
		||||
                Status = entry.Status,
 | 
			
		||||
                SignerIdentity = new SignerIdentityDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Mode = entry.SignerIdentity.Mode,
 | 
			
		||||
                    Issuer = entry.SignerIdentity.Issuer,
 | 
			
		||||
                    SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
 | 
			
		||||
                    KeyId = entry.SignerIdentity.KeyId
 | 
			
		||||
                },
 | 
			
		||||
                Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public AttestorEntry ToDomain()
 | 
			
		||||
        {
 | 
			
		||||
            return new AttestorEntry
 | 
			
		||||
            {
 | 
			
		||||
                RekorUuid = Id,
 | 
			
		||||
                Artifact = new AttestorEntry.ArtifactDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Sha256 = Artifact.Sha256,
 | 
			
		||||
                    Kind = Artifact.Kind,
 | 
			
		||||
                    ImageDigest = Artifact.ImageDigest,
 | 
			
		||||
                    SubjectUri = Artifact.SubjectUri
 | 
			
		||||
                },
 | 
			
		||||
                BundleSha256 = BundleSha256,
 | 
			
		||||
                Index = Index,
 | 
			
		||||
                Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
 | 
			
		||||
                    {
 | 
			
		||||
                        Origin = Proof.Checkpoint.Origin,
 | 
			
		||||
                        Size = Proof.Checkpoint.Size,
 | 
			
		||||
                        RootHash = Proof.Checkpoint.RootHash,
 | 
			
		||||
                        Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
 | 
			
		||||
                    },
 | 
			
		||||
                    Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
 | 
			
		||||
                    {
 | 
			
		||||
                        LeafHash = Proof.Inclusion.LeafHash,
 | 
			
		||||
                        Path = Proof.Inclusion.Path
 | 
			
		||||
                    }
 | 
			
		||||
                },
 | 
			
		||||
                Log = new AttestorEntry.LogDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = Log.Backend,
 | 
			
		||||
                    Url = Log.Url,
 | 
			
		||||
                    LogId = Log.LogId
 | 
			
		||||
                },
 | 
			
		||||
                CreatedAt = CreatedAt.ToUniversalTime(),
 | 
			
		||||
                Status = Status,
 | 
			
		||||
                SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Mode = SignerIdentity.Mode,
 | 
			
		||||
                    Issuer = SignerIdentity.Issuer,
 | 
			
		||||
                    SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
 | 
			
		||||
                    KeyId = SignerIdentity.KeyId
 | 
			
		||||
                },
 | 
			
		||||
                Mirror = Mirror?.ToDomain()
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class ArtifactDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("sha256")]
 | 
			
		||||
            public string Sha256 { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("kind")]
 | 
			
		||||
            public string Kind { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("imageDigest")]
 | 
			
		||||
            public string? ImageDigest { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("subjectUri")]
 | 
			
		||||
            public string? SubjectUri { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class ProofDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("checkpoint")]
 | 
			
		||||
            public CheckpointDocument? Checkpoint { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("inclusion")]
 | 
			
		||||
            public InclusionDocument? Inclusion { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class CheckpointDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("origin")]
 | 
			
		||||
            public string? Origin { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("size")]
 | 
			
		||||
            public long Size { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("rootHash")]
 | 
			
		||||
            public string? RootHash { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("timestamp")]
 | 
			
		||||
            public BsonDateTime? Timestamp { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class InclusionDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("leafHash")]
 | 
			
		||||
            public string? LeafHash { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("path")]
 | 
			
		||||
            public IReadOnlyList<string> Path { get; set; } = System.Array.Empty<string>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class LogDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("backend")]
 | 
			
		||||
            public string Backend { get; set; } = "primary";
 | 
			
		||||
 | 
			
		||||
            [BsonElement("url")]
 | 
			
		||||
            public string Url { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("logId")]
 | 
			
		||||
            public string? LogId { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class SignerIdentityDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("mode")]
 | 
			
		||||
            public string Mode { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("issuer")]
 | 
			
		||||
            public string? Issuer { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("san")]
 | 
			
		||||
            public string? SubjectAlternativeName { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("kid")]
 | 
			
		||||
            public string? KeyId { get; set; }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        internal sealed class MirrorDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("backend")]
 | 
			
		||||
            public string Backend { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("url")]
 | 
			
		||||
            public string Url { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
            [BsonElement("uuid")]
 | 
			
		||||
            public string? Uuid { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("index")]
 | 
			
		||||
            public long? Index { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("status")]
 | 
			
		||||
            public string Status { get; set; } = "pending";
 | 
			
		||||
 | 
			
		||||
            [BsonElement("proof")]
 | 
			
		||||
            public ProofDocument? Proof { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("logId")]
 | 
			
		||||
            public string? LogId { get; set; }
 | 
			
		||||
 | 
			
		||||
            [BsonElement("error")]
 | 
			
		||||
            public string? Error { get; set; }
 | 
			
		||||
 | 
			
		||||
            public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
 | 
			
		||||
            {
 | 
			
		||||
                return new MirrorDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = mirror.Backend,
 | 
			
		||||
                    Url = mirror.Url,
 | 
			
		||||
                    Uuid = mirror.Uuid,
 | 
			
		||||
                    Index = mirror.Index,
 | 
			
		||||
                    Status = mirror.Status,
 | 
			
		||||
                    Proof = mirror.Proof is null ? null : new ProofDocument
 | 
			
		||||
                    {
 | 
			
		||||
                        Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
 | 
			
		||||
                        {
 | 
			
		||||
                            Origin = mirror.Proof.Checkpoint.Origin,
 | 
			
		||||
                            Size = mirror.Proof.Checkpoint.Size,
 | 
			
		||||
                            RootHash = mirror.Proof.Checkpoint.RootHash,
 | 
			
		||||
                            Timestamp = mirror.Proof.Checkpoint.Timestamp is null
 | 
			
		||||
                                ? null
 | 
			
		||||
                                : BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
 | 
			
		||||
                        },
 | 
			
		||||
                        Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
 | 
			
		||||
                        {
 | 
			
		||||
                            LeafHash = mirror.Proof.Inclusion.LeafHash,
 | 
			
		||||
                            Path = mirror.Proof.Inclusion.Path
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    LogId = mirror.LogId,
 | 
			
		||||
                    Error = mirror.Error
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            public AttestorEntry.LogReplicaDescriptor ToDomain()
 | 
			
		||||
            {
 | 
			
		||||
                return new AttestorEntry.LogReplicaDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = Backend,
 | 
			
		||||
                    Url = Url,
 | 
			
		||||
                    Uuid = Uuid,
 | 
			
		||||
                    Index = Index,
 | 
			
		||||
                    Status = Status,
 | 
			
		||||
                    Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
 | 
			
		||||
                    {
 | 
			
		||||
                        Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
 | 
			
		||||
                        {
 | 
			
		||||
                            Origin = Proof.Checkpoint.Origin,
 | 
			
		||||
                            Size = Proof.Checkpoint.Size,
 | 
			
		||||
                            RootHash = Proof.Checkpoint.RootHash,
 | 
			
		||||
                            Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
 | 
			
		||||
                        },
 | 
			
		||||
                        Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
 | 
			
		||||
                        {
 | 
			
		||||
                            LeafHash = Proof.Inclusion.LeafHash,
 | 
			
		||||
                            Path = Proof.Inclusion.Path
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    LogId = LogId,
 | 
			
		||||
                    Error = Error
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,22 @@
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly ILogger<NullAttestorArchiveStore> _logger;
 | 
			
		||||
 | 
			
		||||
    public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        _logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
 | 
			
		||||
        return Task.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,34 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StackExchange.Redis;
 | 
			
		||||
using StellaOps.Attestor.Core.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IDatabase _database;
 | 
			
		||||
    private readonly string _prefix;
 | 
			
		||||
 | 
			
		||||
    public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
 | 
			
		||||
    {
 | 
			
		||||
        _database = multiplexer.GetDatabase();
 | 
			
		||||
        _prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
 | 
			
		||||
        return value.HasValue ? value.ToString() : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,72 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Amazon.S3;
 | 
			
		||||
using Amazon.S3.Model;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposable
 | 
			
		||||
{
 | 
			
		||||
    private readonly IAmazonS3 _s3;
 | 
			
		||||
    private readonly AttestorOptions.S3Options _options;
 | 
			
		||||
    private readonly ILogger<S3AttestorArchiveStore> _logger;
 | 
			
		||||
    private bool _disposed;
 | 
			
		||||
 | 
			
		||||
    public S3AttestorArchiveStore(IAmazonS3 s3, IOptions<AttestorOptions> options, ILogger<S3AttestorArchiveStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        _s3 = s3;
 | 
			
		||||
        _options = options.Value.S3;
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(_options.Bucket))
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning("S3 archive bucket is not configured; skipping archive for bundle {Bundle}", bundle.BundleSha256);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var prefix = _options.Prefix ?? "attest/";
 | 
			
		||||
 | 
			
		||||
        await PutObjectAsync(prefix + "dsse/" + bundle.BundleSha256 + ".json", bundle.CanonicalBundleJson, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (bundle.ProofJson.Length > 0)
 | 
			
		||||
        {
 | 
			
		||||
            await PutObjectAsync(prefix + "proof/" + bundle.RekorUuid + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var metadataObject = JsonSerializer.SerializeToUtf8Bytes(bundle.Metadata);
 | 
			
		||||
        await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Task PutObjectAsync(string key, byte[] content, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        using var stream = new MemoryStream(content);
 | 
			
		||||
        var request = new PutObjectRequest
 | 
			
		||||
        {
 | 
			
		||||
            BucketName = _options.Bucket,
 | 
			
		||||
            Key = key,
 | 
			
		||||
            InputStream = stream,
 | 
			
		||||
            AutoCloseStream = false
 | 
			
		||||
        };
 | 
			
		||||
        return _s3.PutObjectAsync(request, cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void Dispose()
 | 
			
		||||
    {
 | 
			
		||||
        if (_disposed)
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _s3.Dispose();
 | 
			
		||||
        _disposed = true;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,624 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Diagnostics;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Audit;
 | 
			
		||||
using StellaOps.Attestor.Core.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Observability;
 | 
			
		||||
using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Submission;
 | 
			
		||||
 | 
			
		||||
internal sealed class AttestorSubmissionService : IAttestorSubmissionService
 | 
			
		||||
{
 | 
			
		||||
    private static readonly TimeSpan DedupeTtl = TimeSpan.FromHours(48);
 | 
			
		||||
 | 
			
		||||
    private readonly AttestorSubmissionValidator _validator;
 | 
			
		||||
    private readonly IAttestorEntryRepository _repository;
 | 
			
		||||
    private readonly IAttestorDedupeStore _dedupeStore;
 | 
			
		||||
    private readonly IRekorClient _rekorClient;
 | 
			
		||||
    private readonly IAttestorArchiveStore _archiveStore;
 | 
			
		||||
    private readonly IAttestorAuditSink _auditSink;
 | 
			
		||||
    private readonly ILogger<AttestorSubmissionService> _logger;
 | 
			
		||||
    private readonly TimeProvider _timeProvider;
 | 
			
		||||
    private readonly AttestorOptions _options;
 | 
			
		||||
    private readonly AttestorMetrics _metrics;
 | 
			
		||||
 | 
			
		||||
    public AttestorSubmissionService(
 | 
			
		||||
        AttestorSubmissionValidator validator,
 | 
			
		||||
        IAttestorEntryRepository repository,
 | 
			
		||||
        IAttestorDedupeStore dedupeStore,
 | 
			
		||||
        IRekorClient rekorClient,
 | 
			
		||||
        IAttestorArchiveStore archiveStore,
 | 
			
		||||
        IAttestorAuditSink auditSink,
 | 
			
		||||
        IOptions<AttestorOptions> options,
 | 
			
		||||
        ILogger<AttestorSubmissionService> logger,
 | 
			
		||||
        TimeProvider timeProvider,
 | 
			
		||||
        AttestorMetrics metrics)
 | 
			
		||||
    {
 | 
			
		||||
        _validator = validator;
 | 
			
		||||
        _repository = repository;
 | 
			
		||||
        _dedupeStore = dedupeStore;
 | 
			
		||||
        _rekorClient = rekorClient;
 | 
			
		||||
        _archiveStore = archiveStore;
 | 
			
		||||
        _auditSink = auditSink;
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
        _timeProvider = timeProvider;
 | 
			
		||||
        _options = options.Value;
 | 
			
		||||
        _metrics = metrics;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AttestorSubmissionResult> SubmitAsync(
 | 
			
		||||
        AttestorSubmissionRequest request,
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(request);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(context);
 | 
			
		||||
 | 
			
		||||
        var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var canonicalBundle = validation.CanonicalBundle;
 | 
			
		||||
 | 
			
		||||
        var preference = NormalizeLogPreference(request.Meta.LogPreference);
 | 
			
		||||
        var requiresPrimary = preference is "primary" or "both";
 | 
			
		||||
        var requiresMirror = preference is "mirror" or "both";
 | 
			
		||||
 | 
			
		||||
        if (!requiresPrimary && !requiresMirror)
 | 
			
		||||
        {
 | 
			
		||||
            requiresPrimary = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (requiresMirror && !_options.Rekor.Mirror.Enabled)
 | 
			
		||||
        {
 | 
			
		||||
            throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (existing is not null)
 | 
			
		||||
        {
 | 
			
		||||
            _metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
 | 
			
		||||
            var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return ToResult(updated);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
 | 
			
		||||
 | 
			
		||||
        SubmissionOutcome? canonicalOutcome = null;
 | 
			
		||||
        SubmissionOutcome? mirrorOutcome = null;
 | 
			
		||||
 | 
			
		||||
        if (requiresPrimary)
 | 
			
		||||
        {
 | 
			
		||||
            canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (requiresMirror)
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (canonicalOutcome is null)
 | 
			
		||||
                {
 | 
			
		||||
                    canonicalOutcome = mirror;
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    mirrorOutcome = mirror;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                if (canonicalOutcome is null)
 | 
			
		||||
                {
 | 
			
		||||
                    throw;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
 | 
			
		||||
                _logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
 | 
			
		||||
                mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
 | 
			
		||||
                RecordSubmissionMetrics(mirrorOutcome);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (canonicalOutcome is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("No Rekor submission outcome was produced.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
 | 
			
		||||
        await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (request.Meta.Archive)
 | 
			
		||||
        {
 | 
			
		||||
            await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (mirrorOutcome is not null)
 | 
			
		||||
        {
 | 
			
		||||
            await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ToResult(entry);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorSubmissionResult ToResult(AttestorEntry entry)
 | 
			
		||||
    {
 | 
			
		||||
        var result = new AttestorSubmissionResult
 | 
			
		||||
        {
 | 
			
		||||
            Uuid = entry.RekorUuid,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            LogUrl = entry.Log.Url,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            Proof = ToResultProof(entry.Proof)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (entry.Mirror is not null)
 | 
			
		||||
        {
 | 
			
		||||
            result.Mirror = new AttestorSubmissionResult.MirrorLog
 | 
			
		||||
            {
 | 
			
		||||
                Uuid = entry.Mirror.Uuid,
 | 
			
		||||
                Index = entry.Mirror.Index,
 | 
			
		||||
                LogUrl = entry.Mirror.Url,
 | 
			
		||||
                Status = entry.Mirror.Status,
 | 
			
		||||
                Proof = ToResultProof(entry.Mirror.Proof),
 | 
			
		||||
                Error = entry.Mirror.Error
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private AttestorEntry CreateEntry(
 | 
			
		||||
        AttestorSubmissionRequest request,
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        SubmissionOutcome canonicalOutcome,
 | 
			
		||||
        SubmissionOutcome? mirrorOutcome)
 | 
			
		||||
    {
 | 
			
		||||
        if (canonicalOutcome.Submission is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var submission = canonicalOutcome.Submission;
 | 
			
		||||
        var now = _timeProvider.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        return new AttestorEntry
 | 
			
		||||
        {
 | 
			
		||||
            RekorUuid = submission.Uuid,
 | 
			
		||||
            Artifact = new AttestorEntry.ArtifactDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Sha256 = request.Meta.Artifact.Sha256,
 | 
			
		||||
                Kind = request.Meta.Artifact.Kind,
 | 
			
		||||
                ImageDigest = request.Meta.Artifact.ImageDigest,
 | 
			
		||||
                SubjectUri = request.Meta.Artifact.SubjectUri
 | 
			
		||||
            },
 | 
			
		||||
            BundleSha256 = request.Meta.BundleSha256,
 | 
			
		||||
            Index = submission.Index,
 | 
			
		||||
            Proof = ConvertProof(canonicalOutcome.Proof),
 | 
			
		||||
            Log = new AttestorEntry.LogDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Backend = canonicalOutcome.Backend,
 | 
			
		||||
                Url = submission.LogUrl ?? canonicalOutcome.Url,
 | 
			
		||||
                LogId = null
 | 
			
		||||
            },
 | 
			
		||||
            CreatedAt = now,
 | 
			
		||||
            Status = submission.Status ?? "included",
 | 
			
		||||
            SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Mode = request.Bundle.Mode,
 | 
			
		||||
                Issuer = context.CallerAudience,
 | 
			
		||||
                SubjectAlternativeName = context.CallerSubject,
 | 
			
		||||
                KeyId = context.CallerClientId
 | 
			
		||||
            },
 | 
			
		||||
            Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string NormalizeLogPreference(string? value)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            return "primary";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = value.Trim().ToLowerInvariant();
 | 
			
		||||
        return normalized switch
 | 
			
		||||
        {
 | 
			
		||||
            "primary" => "primary",
 | 
			
		||||
            "mirror" => "mirror",
 | 
			
		||||
            "both" => "both",
 | 
			
		||||
            _ => "primary"
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(dedupeUuid))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
            ?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry> EnsureBackendsAsync(
 | 
			
		||||
        AttestorEntry existing,
 | 
			
		||||
        AttestorSubmissionRequest request,
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        bool requiresPrimary,
 | 
			
		||||
        bool requiresMirror,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var entry = existing;
 | 
			
		||||
        var updated = false;
 | 
			
		||||
 | 
			
		||||
        if (requiresPrimary && !IsPrimary(entry))
 | 
			
		||||
        {
 | 
			
		||||
            var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            entry = PromoteToPrimary(entry, outcome);
 | 
			
		||||
            await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            updated = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (requiresMirror)
 | 
			
		||||
        {
 | 
			
		||||
            var mirrorSatisfied = entry.Mirror is not null
 | 
			
		||||
                && entry.Mirror.Error is null
 | 
			
		||||
                && string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
 | 
			
		||||
                && !string.IsNullOrEmpty(entry.Mirror.Uuid);
 | 
			
		||||
 | 
			
		||||
            if (!mirrorSatisfied)
 | 
			
		||||
            {
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    entry = WithMirror(entry, mirrorOutcome);
 | 
			
		||||
                    await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    updated = true;
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception ex)
 | 
			
		||||
                {
 | 
			
		||||
                    _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
 | 
			
		||||
                    _logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
 | 
			
		||||
                    var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
 | 
			
		||||
                    RecordSubmissionMetrics(failure);
 | 
			
		||||
                    entry = WithMirror(entry, failure);
 | 
			
		||||
                    await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    updated = true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!updated)
 | 
			
		||||
        {
 | 
			
		||||
            _metrics.SubmitTotal.Add(1,
 | 
			
		||||
                new KeyValuePair<string, object?>("result", "dedupe"),
 | 
			
		||||
                new KeyValuePair<string, object?>("backend", "cache"));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return entry;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool IsPrimary(AttestorEntry entry) =>
 | 
			
		||||
        string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    private async Task<SubmissionOutcome> SubmitToBackendAsync(
 | 
			
		||||
        AttestorSubmissionRequest request,
 | 
			
		||||
        string backendName,
 | 
			
		||||
        AttestorOptions.RekorBackendOptions backendOptions,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var backend = BuildBackend(backendName, backendOptions);
 | 
			
		||||
        var stopwatch = Stopwatch.StartNew();
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            stopwatch.Stop();
 | 
			
		||||
 | 
			
		||||
            var proof = submission.Proof;
 | 
			
		||||
            if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    _metrics.ProofFetchTotal.Add(1,
 | 
			
		||||
                        new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception ex)
 | 
			
		||||
                {
 | 
			
		||||
                    _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
 | 
			
		||||
                    _logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submission.Uuid, backendName);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
 | 
			
		||||
            RecordSubmissionMetrics(outcome);
 | 
			
		||||
            return outcome;
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            stopwatch.Stop();
 | 
			
		||||
            _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
 | 
			
		||||
            _logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
 | 
			
		||||
            throw;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void RecordSubmissionMetrics(SubmissionOutcome outcome)
 | 
			
		||||
    {
 | 
			
		||||
        var result = outcome.IsSuccess
 | 
			
		||||
            ? outcome.Submission!.Status ?? "unknown"
 | 
			
		||||
            : "failed";
 | 
			
		||||
 | 
			
		||||
        _metrics.SubmitTotal.Add(1,
 | 
			
		||||
            new KeyValuePair<string, object?>("result", result),
 | 
			
		||||
            new KeyValuePair<string, object?>("backend", outcome.Backend));
 | 
			
		||||
 | 
			
		||||
        if (outcome.Latency > TimeSpan.Zero)
 | 
			
		||||
        {
 | 
			
		||||
            _metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
 | 
			
		||||
                new KeyValuePair<string, object?>("backend", outcome.Backend));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task ArchiveAsync(
 | 
			
		||||
        AttestorEntry entry,
 | 
			
		||||
        byte[] canonicalBundle,
 | 
			
		||||
        RekorProofResponse? proof,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var metadata = new Dictionary<string, string>
 | 
			
		||||
        {
 | 
			
		||||
            ["logUrl"] = entry.Log.Url,
 | 
			
		||||
            ["status"] = entry.Status
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (entry.Mirror is not null)
 | 
			
		||||
        {
 | 
			
		||||
            metadata["mirror.backend"] = entry.Mirror.Backend;
 | 
			
		||||
            metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
 | 
			
		||||
            metadata["mirror.status"] = entry.Mirror.Status;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var archiveBundle = new AttestorArchiveBundle
 | 
			
		||||
        {
 | 
			
		||||
            RekorUuid = entry.RekorUuid,
 | 
			
		||||
            ArtifactSha256 = entry.Artifact.Sha256,
 | 
			
		||||
            BundleSha256 = entry.BundleSha256,
 | 
			
		||||
            CanonicalBundleJson = canonicalBundle,
 | 
			
		||||
            ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
 | 
			
		||||
            Metadata = metadata
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
 | 
			
		||||
            _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Task WriteAuditAsync(
 | 
			
		||||
        AttestorSubmissionRequest request,
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        AttestorEntry entry,
 | 
			
		||||
        SubmissionOutcome outcome,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var metadata = new Dictionary<string, string>();
 | 
			
		||||
        if (!outcome.IsSuccess && outcome.Error is not null)
 | 
			
		||||
        {
 | 
			
		||||
            metadata["error"] = outcome.Error.Message;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var record = new AttestorAuditRecord
 | 
			
		||||
        {
 | 
			
		||||
            Action = "submit",
 | 
			
		||||
            Result = outcome.IsSuccess
 | 
			
		||||
                ? outcome.Submission!.Status ?? "included"
 | 
			
		||||
                : "failed",
 | 
			
		||||
            RekorUuid = outcome.IsSuccess
 | 
			
		||||
                ? outcome.Submission!.Uuid
 | 
			
		||||
                : string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
 | 
			
		||||
                    ? entry.RekorUuid
 | 
			
		||||
                    : entry.Mirror?.Uuid,
 | 
			
		||||
            Index = outcome.Submission?.Index,
 | 
			
		||||
            ArtifactSha256 = request.Meta.Artifact.Sha256,
 | 
			
		||||
            BundleSha256 = request.Meta.BundleSha256,
 | 
			
		||||
            Backend = outcome.Backend,
 | 
			
		||||
            LatencyMs = (long)outcome.Latency.TotalMilliseconds,
 | 
			
		||||
            Timestamp = _timeProvider.GetUtcNow(),
 | 
			
		||||
            Caller = new AttestorAuditRecord.CallerDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Subject = context.CallerSubject,
 | 
			
		||||
                Audience = context.CallerAudience,
 | 
			
		||||
                ClientId = context.CallerClientId,
 | 
			
		||||
                MtlsThumbprint = context.MtlsThumbprint,
 | 
			
		||||
                Tenant = context.CallerTenant
 | 
			
		||||
            },
 | 
			
		||||
            Metadata = metadata
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return _auditSink.WriteAsync(record, cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
 | 
			
		||||
    {
 | 
			
		||||
        if (proof is null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new AttestorEntry.ProofDescriptor
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Origin = proof.Checkpoint.Origin,
 | 
			
		||||
                Size = proof.Checkpoint.Size,
 | 
			
		||||
                RootHash = proof.Checkpoint.RootHash,
 | 
			
		||||
                Timestamp = proof.Checkpoint.Timestamp
 | 
			
		||||
            },
 | 
			
		||||
            Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                LeafHash = proof.Inclusion.LeafHash,
 | 
			
		||||
                Path = proof.Inclusion.Path
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
 | 
			
		||||
    {
 | 
			
		||||
        if (proof is null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new AttestorSubmissionResult.RekorProof
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
 | 
			
		||||
            {
 | 
			
		||||
                Origin = proof.Checkpoint.Origin,
 | 
			
		||||
                Size = proof.Checkpoint.Size,
 | 
			
		||||
                RootHash = proof.Checkpoint.RootHash,
 | 
			
		||||
                Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
 | 
			
		||||
            },
 | 
			
		||||
            Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
 | 
			
		||||
            {
 | 
			
		||||
                LeafHash = proof.Inclusion.LeafHash,
 | 
			
		||||
                Path = proof.Inclusion.Path
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorEntry.LogReplicaDescriptor
 | 
			
		||||
        {
 | 
			
		||||
            Backend = outcome.Backend,
 | 
			
		||||
            Url = outcome.IsSuccess
 | 
			
		||||
                ? outcome.Submission!.LogUrl ?? outcome.Url
 | 
			
		||||
                : outcome.Url,
 | 
			
		||||
            Uuid = outcome.Submission?.Uuid,
 | 
			
		||||
            Index = outcome.Submission?.Index,
 | 
			
		||||
            Status = outcome.IsSuccess
 | 
			
		||||
                ? outcome.Submission!.Status ?? "included"
 | 
			
		||||
                : "failed",
 | 
			
		||||
            Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
 | 
			
		||||
            Error = outcome.Error?.Message
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorEntry
 | 
			
		||||
        {
 | 
			
		||||
            RekorUuid = entry.RekorUuid,
 | 
			
		||||
            Artifact = entry.Artifact,
 | 
			
		||||
            BundleSha256 = entry.BundleSha256,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            Proof = entry.Proof,
 | 
			
		||||
            Log = entry.Log,
 | 
			
		||||
            CreatedAt = entry.CreatedAt,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            SignerIdentity = entry.SignerIdentity,
 | 
			
		||||
            Mirror = CreateMirrorDescriptor(outcome)
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
 | 
			
		||||
    {
 | 
			
		||||
        if (outcome.Submission is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var mirrorDescriptor = existing.Mirror;
 | 
			
		||||
        if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new AttestorEntry
 | 
			
		||||
        {
 | 
			
		||||
            RekorUuid = outcome.Submission.Uuid,
 | 
			
		||||
            Artifact = existing.Artifact,
 | 
			
		||||
            BundleSha256 = existing.BundleSha256,
 | 
			
		||||
            Index = outcome.Submission.Index,
 | 
			
		||||
            Proof = ConvertProof(outcome.Proof),
 | 
			
		||||
            Log = new AttestorEntry.LogDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Backend = outcome.Backend,
 | 
			
		||||
                Url = outcome.Submission.LogUrl ?? outcome.Url,
 | 
			
		||||
                LogId = existing.Log.LogId
 | 
			
		||||
            },
 | 
			
		||||
            CreatedAt = existing.CreatedAt,
 | 
			
		||||
            Status = outcome.Submission.Status ?? "included",
 | 
			
		||||
            SignerIdentity = existing.SignerIdentity,
 | 
			
		||||
            Mirror = mirrorDescriptor
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorEntry.LogReplicaDescriptor
 | 
			
		||||
        {
 | 
			
		||||
            Backend = entry.Log.Backend,
 | 
			
		||||
            Url = entry.Log.Url,
 | 
			
		||||
            Uuid = entry.RekorUuid,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            Proof = entry.Proof,
 | 
			
		||||
            LogId = entry.Log.LogId
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record SubmissionOutcome(
 | 
			
		||||
        string Backend,
 | 
			
		||||
        string Url,
 | 
			
		||||
        RekorSubmissionResponse? Submission,
 | 
			
		||||
        RekorProofResponse? Proof,
 | 
			
		||||
        TimeSpan Latency,
 | 
			
		||||
        Exception? Error)
 | 
			
		||||
    {
 | 
			
		||||
        public bool IsSuccess => Submission is not null && Error is null;
 | 
			
		||||
 | 
			
		||||
        public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
 | 
			
		||||
            new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
 | 
			
		||||
 | 
			
		||||
        public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
 | 
			
		||||
            new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(options.Url))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new RekorBackend
 | 
			
		||||
        {
 | 
			
		||||
            Name = name,
 | 
			
		||||
            Url = new Uri(options.Url, UriKind.Absolute),
 | 
			
		||||
            ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
 | 
			
		||||
            PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
 | 
			
		||||
            MaxAttempts = options.MaxAttempts
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,49 @@
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Nodes;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Submission;
 | 
			
		||||
 | 
			
		||||
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
 | 
			
		||||
{
 | 
			
		||||
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
 | 
			
		||||
    {
 | 
			
		||||
        WriteIndented = false,
 | 
			
		||||
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var node = new JsonObject
 | 
			
		||||
        {
 | 
			
		||||
            ["payloadType"] = request.Bundle.Dsse.PayloadType,
 | 
			
		||||
            ["payload"] = request.Bundle.Dsse.PayloadBase64,
 | 
			
		||||
            ["signatures"] = CreateSignaturesArray(request)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var json = node.ToJsonString(SerializerOptions);
 | 
			
		||||
        return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
 | 
			
		||||
    {
 | 
			
		||||
        var array = new JsonArray();
 | 
			
		||||
        foreach (var signature in request.Bundle.Dsse.Signatures)
 | 
			
		||||
        {
 | 
			
		||||
            var obj = new JsonObject
 | 
			
		||||
            {
 | 
			
		||||
                ["sig"] = signature.Signature
 | 
			
		||||
            };
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(signature.KeyId))
 | 
			
		||||
            {
 | 
			
		||||
                obj["keyid"] = signature.KeyId;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            array.Add(obj);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return array;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,754 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Buffers.Binary;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Security.Cryptography.X509Certificates;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Options;
 | 
			
		||||
using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
using StellaOps.Attestor.Core.Verification;
 | 
			
		||||
using StellaOps.Attestor.Core.Observability;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Verification;
 | 
			
		||||
 | 
			
		||||
internal sealed class AttestorVerificationService : IAttestorVerificationService
 | 
			
		||||
{
 | 
			
		||||
    private readonly IAttestorEntryRepository _repository;
 | 
			
		||||
    private readonly IDsseCanonicalizer _canonicalizer;
 | 
			
		||||
    private readonly IRekorClient _rekorClient;
 | 
			
		||||
    private readonly ILogger<AttestorVerificationService> _logger;
 | 
			
		||||
    private readonly AttestorOptions _options;
 | 
			
		||||
    private readonly AttestorMetrics _metrics;
 | 
			
		||||
 | 
			
		||||
    public AttestorVerificationService(
 | 
			
		||||
        IAttestorEntryRepository repository,
 | 
			
		||||
        IDsseCanonicalizer canonicalizer,
 | 
			
		||||
        IRekorClient rekorClient,
 | 
			
		||||
        IOptions<AttestorOptions> options,
 | 
			
		||||
        ILogger<AttestorVerificationService> logger,
 | 
			
		||||
        AttestorMetrics metrics)
 | 
			
		||||
    {
 | 
			
		||||
        _repository = repository;
 | 
			
		||||
        _canonicalizer = canonicalizer;
 | 
			
		||||
        _rekorClient = rekorClient;
 | 
			
		||||
        _logger = logger;
 | 
			
		||||
        _options = options.Value;
 | 
			
		||||
        _metrics = metrics;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        if (request is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(request));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var entry = await ResolveEntryAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (entry is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new AttestorVerificationException("not_found", "No attestor entry matched the supplied query.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var issues = new List<string>();
 | 
			
		||||
 | 
			
		||||
        if (request.Bundle is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var canonicalBundle = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
 | 
			
		||||
            {
 | 
			
		||||
                Bundle = request.Bundle,
 | 
			
		||||
                Meta = new AttestorSubmissionRequest.SubmissionMeta
 | 
			
		||||
                {
 | 
			
		||||
                    Artifact = new AttestorSubmissionRequest.ArtifactInfo
 | 
			
		||||
                    {
 | 
			
		||||
                        Sha256 = entry.Artifact.Sha256,
 | 
			
		||||
                        Kind = entry.Artifact.Kind
 | 
			
		||||
                    },
 | 
			
		||||
                    BundleSha256 = entry.BundleSha256
 | 
			
		||||
                }
 | 
			
		||||
            }, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
 | 
			
		||||
            if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("bundle_hash_mismatch");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("bundle_payload_invalid_base64");
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
 | 
			
		||||
                VerifySignatures(entry, request.Bundle, preAuth, issues);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (request.RefreshProof || entry.Proof is null)
 | 
			
		||||
        {
 | 
			
		||||
            var backend = BuildBackend("primary", _options.Rekor.Primary);
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var proof = await _rekorClient.GetProofAsync(entry.RekorUuid, backend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (proof is not null)
 | 
			
		||||
                {
 | 
			
		||||
                    var updated = CloneWithProof(entry, proof.ToProofDescriptor());
 | 
			
		||||
                    await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                    entry = updated;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", entry.RekorUuid);
 | 
			
		||||
                issues.Add("Proof refresh failed: " + ex.Message);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        VerifyMerkleProof(entry, issues);
 | 
			
		||||
 | 
			
		||||
        var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        _metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
 | 
			
		||||
 | 
			
		||||
        return new AttestorVerificationResult
 | 
			
		||||
        {
 | 
			
		||||
            Ok = ok,
 | 
			
		||||
            Uuid = entry.RekorUuid,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            LogUrl = entry.Log.Url,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            Issues = issues,
 | 
			
		||||
            CheckedAt = DateTimeOffset.UtcNow
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(rekorUuid))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Value cannot be null or whitespace.", nameof(rekorUuid));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ResolveEntryByUuidAsync(rekorUuid, refreshProof, cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry?> ResolveEntryAsync(AttestorVerificationRequest request, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(request.Uuid))
 | 
			
		||||
        {
 | 
			
		||||
            return await ResolveEntryByUuidAsync(request.Uuid, request.RefreshProof, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (request.Bundle is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var canonical = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
 | 
			
		||||
            {
 | 
			
		||||
                Bundle = request.Bundle,
 | 
			
		||||
                Meta = new AttestorSubmissionRequest.SubmissionMeta
 | 
			
		||||
                {
 | 
			
		||||
                    Artifact = new AttestorSubmissionRequest.ArtifactInfo
 | 
			
		||||
                    {
 | 
			
		||||
                        Sha256 = string.Empty,
 | 
			
		||||
                        Kind = string.Empty
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            var bundleSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonical)).ToLowerInvariant();
 | 
			
		||||
            return await ResolveEntryByBundleShaAsync(bundleSha, request.RefreshProof, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(request.ArtifactSha256))
 | 
			
		||||
        {
 | 
			
		||||
            return await ResolveEntryByArtifactAsync(request.ArtifactSha256, request.RefreshProof, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new AttestorVerificationException("invalid_query", "At least one of uuid, bundle, or artifactSha256 must be provided.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry?> ResolveEntryByUuidAsync(string uuid, bool refreshProof, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var entry = await _repository.GetByUuidAsync(uuid, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (entry is null || !refreshProof)
 | 
			
		||||
        {
 | 
			
		||||
            return entry;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var backend = BuildBackend("primary", _options.Rekor.Primary);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var proof = await _rekorClient.GetProofAsync(uuid, backend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (proof is not null)
 | 
			
		||||
            {
 | 
			
		||||
                var updated = CloneWithProof(entry, proof.ToProofDescriptor());
 | 
			
		||||
                await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                entry = updated;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", uuid);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return entry;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry?> ResolveEntryByBundleShaAsync(string bundleSha, bool refreshProof, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var entry = await _repository.GetByBundleShaAsync(bundleSha, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (entry is null || !refreshProof)
 | 
			
		||||
        {
 | 
			
		||||
            return entry;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<AttestorEntry?> ResolveEntryByArtifactAsync(string artifactSha256, bool refreshProof, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var entries = await _repository.GetByArtifactShaAsync(artifactSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        var entry = entries.OrderByDescending(e => e.CreatedAt).FirstOrDefault();
 | 
			
		||||
        if (entry is null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return refreshProof
 | 
			
		||||
            ? await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
            : entry;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
 | 
			
		||||
    {
 | 
			
		||||
        var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
 | 
			
		||||
 | 
			
		||||
        if (mode == "kms")
 | 
			
		||||
        {
 | 
			
		||||
            if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("signature_invalid_kms");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (mode == "keyless")
 | 
			
		||||
        {
 | 
			
		||||
            VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        issues.Add(string.IsNullOrEmpty(mode)
 | 
			
		||||
            ? "signer_mode_unknown"
 | 
			
		||||
            : $"signer_mode_unsupported:{mode}");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
 | 
			
		||||
    {
 | 
			
		||||
        if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("kms_key_missing");
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var signatures = new List<byte[]>();
 | 
			
		||||
        foreach (var signature in bundle.Dsse.Signatures)
 | 
			
		||||
        {
 | 
			
		||||
            if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("signature_invalid_base64");
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            signatures.Add(signatureBytes);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
 | 
			
		||||
        {
 | 
			
		||||
            if (!TryDecodeSecret(secret, out var secretBytes))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using var hmac = new HMACSHA256(secretBytes);
 | 
			
		||||
            var computed = hmac.ComputeHash(preAuthEncoding);
 | 
			
		||||
 | 
			
		||||
            foreach (var signatureBytes in signatures)
 | 
			
		||||
            {
 | 
			
		||||
                if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
 | 
			
		||||
                {
 | 
			
		||||
                    return true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
 | 
			
		||||
    {
 | 
			
		||||
        if (bundle.CertificateChain.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("certificate_chain_missing");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var certificates = new List<X509Certificate2>();
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var pem in bundle.CertificateChain)
 | 
			
		||||
            {
 | 
			
		||||
                certificates.Add(X509Certificate2.CreateFromPem(pem));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex) when (ex is CryptographicException or ArgumentException)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("certificate_chain_invalid");
 | 
			
		||||
            _logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var leafCertificate = certificates[0];
 | 
			
		||||
 | 
			
		||||
        if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            using var chain = new X509Chain
 | 
			
		||||
            {
 | 
			
		||||
                ChainPolicy =
 | 
			
		||||
                {
 | 
			
		||||
                    RevocationMode = X509RevocationMode.NoCheck,
 | 
			
		||||
                    VerificationFlags = X509VerificationFlags.NoFlag,
 | 
			
		||||
                    TrustMode = X509ChainTrustMode.CustomRootTrust
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
 | 
			
		||||
            {
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    if (File.Exists(rootPath))
 | 
			
		||||
                    {
 | 
			
		||||
                        var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
 | 
			
		||||
                        chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception ex)
 | 
			
		||||
                {
 | 
			
		||||
                    _logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!chain.Build(leafCertificate))
 | 
			
		||||
            {
 | 
			
		||||
                var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
 | 
			
		||||
                    .Trim(';');
 | 
			
		||||
                issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var sans = GetSubjectAlternativeNames(leafCertificate);
 | 
			
		||||
            if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("certificate_san_untrusted");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var signatureVerified = false;
 | 
			
		||||
        foreach (var signature in bundle.Dsse.Signatures)
 | 
			
		||||
        {
 | 
			
		||||
            if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("signature_invalid_base64");
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
 | 
			
		||||
            {
 | 
			
		||||
                signatureVerified = true;
 | 
			
		||||
                break;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!signatureVerified)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("signature_invalid");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var ecdsa = certificate.GetECDsaPublicKey();
 | 
			
		||||
            if (ecdsa is not null)
 | 
			
		||||
            {
 | 
			
		||||
                using (ecdsa)
 | 
			
		||||
                {
 | 
			
		||||
                    return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var rsa = certificate.GetRSAPublicKey();
 | 
			
		||||
            if (rsa is not null)
 | 
			
		||||
            {
 | 
			
		||||
                using (rsa)
 | 
			
		||||
                {
 | 
			
		||||
                    return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (CryptographicException)
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
 | 
			
		||||
    {
 | 
			
		||||
        foreach (var extension in certificate.Extensions)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var formatted = extension.Format(true);
 | 
			
		||||
            var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
 | 
			
		||||
            foreach (var line in lines)
 | 
			
		||||
            {
 | 
			
		||||
                var parts = line.Split('=');
 | 
			
		||||
                if (parts.Length == 2)
 | 
			
		||||
                {
 | 
			
		||||
                    yield return parts[1].Trim();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
 | 
			
		||||
    {
 | 
			
		||||
        var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
 | 
			
		||||
        var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
 | 
			
		||||
        var offset = 0;
 | 
			
		||||
 | 
			
		||||
        Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
 | 
			
		||||
        offset += 6;
 | 
			
		||||
 | 
			
		||||
        BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
 | 
			
		||||
        offset += 8;
 | 
			
		||||
        Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
 | 
			
		||||
        offset += headerBytes.Length;
 | 
			
		||||
 | 
			
		||||
        BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
 | 
			
		||||
        offset += 8;
 | 
			
		||||
        Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
 | 
			
		||||
 | 
			
		||||
        return buffer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
 | 
			
		||||
    {
 | 
			
		||||
        if (entry.Proof is null)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("proof_missing");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("bundle_hash_decode_failed");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (entry.Proof.Inclusion is null)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("proof_inclusion_missing");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (entry.Proof.Inclusion.LeafHash is not null)
 | 
			
		||||
        {
 | 
			
		||||
            if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("proof_leafhash_decode_failed");
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("proof_leafhash_mismatch");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var current = bundleHash;
 | 
			
		||||
 | 
			
		||||
        if (entry.Proof.Inclusion.Path.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var nodes = new List<ProofPathNode>();
 | 
			
		||||
            foreach (var element in entry.Proof.Inclusion.Path)
 | 
			
		||||
            {
 | 
			
		||||
                if (!ProofPathNode.TryParse(element, out var node))
 | 
			
		||||
                {
 | 
			
		||||
                    issues.Add("proof_path_decode_failed");
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!node.HasOrientation)
 | 
			
		||||
                {
 | 
			
		||||
                    issues.Add("proof_path_orientation_missing");
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                nodes.Add(node);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var node in nodes)
 | 
			
		||||
            {
 | 
			
		||||
                current = node.Left
 | 
			
		||||
                    ? HashInternal(node.Hash, current)
 | 
			
		||||
                    : HashInternal(current, node.Hash);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (entry.Proof.Checkpoint is null)
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("checkpoint_missing");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("checkpoint_root_decode_failed");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
 | 
			
		||||
        {
 | 
			
		||||
            issues.Add("proof_root_mismatch");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static byte[] HashInternal(byte[] left, byte[] right)
 | 
			
		||||
    {
 | 
			
		||||
        using var sha = SHA256.Create();
 | 
			
		||||
        var buffer = new byte[1 + left.Length + right.Length];
 | 
			
		||||
        buffer[0] = 0x01;
 | 
			
		||||
        Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
 | 
			
		||||
        Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
 | 
			
		||||
        return sha.ComputeHash(buffer);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TryDecodeSecret(string value, out byte[] bytes)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            bytes = Array.Empty<byte>();
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        value = value.Trim();
 | 
			
		||||
 | 
			
		||||
        if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return TryDecodeBase64(value[7..], out bytes);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return TryDecodeHex(value[4..], out bytes);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (TryDecodeBase64(value, out bytes))
 | 
			
		||||
        {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (TryDecodeHex(value, out bytes))
 | 
			
		||||
        {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        bytes = Array.Empty<byte>();
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TryDecodeBase64(string value, out byte[] bytes)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            bytes = Convert.FromBase64String(value);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        catch (FormatException)
 | 
			
		||||
        {
 | 
			
		||||
            bytes = Array.Empty<byte>();
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TryDecodeHex(string value, out byte[] bytes)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            bytes = Convert.FromHexString(value);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        catch (FormatException)
 | 
			
		||||
        {
 | 
			
		||||
            bytes = Array.Empty<byte>();
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool TryDecodeHash(string? value, out byte[] bytes)
 | 
			
		||||
    {
 | 
			
		||||
        bytes = Array.Empty<byte>();
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
        {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var trimmed = value.Trim();
 | 
			
		||||
 | 
			
		||||
        if (TryDecodeHex(trimmed, out bytes))
 | 
			
		||||
        {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (TryDecodeBase64(trimmed, out bytes))
 | 
			
		||||
        {
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        bytes = Array.Empty<byte>();
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private readonly struct ProofPathNode
 | 
			
		||||
    {
 | 
			
		||||
        private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
 | 
			
		||||
        {
 | 
			
		||||
            HasOrientation = hasOrientation;
 | 
			
		||||
            Left = left;
 | 
			
		||||
            Hash = hash;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public bool HasOrientation { get; }
 | 
			
		||||
 | 
			
		||||
        public bool Left { get; }
 | 
			
		||||
 | 
			
		||||
        public byte[] Hash { get; }
 | 
			
		||||
 | 
			
		||||
        public static bool TryParse(string value, out ProofPathNode node)
 | 
			
		||||
        {
 | 
			
		||||
            node = default;
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(value))
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var trimmed = value.Trim();
 | 
			
		||||
            var parts = trimmed.Split(':', 2);
 | 
			
		||||
            bool hasOrientation = false;
 | 
			
		||||
            bool left = false;
 | 
			
		||||
            string hashPart = trimmed;
 | 
			
		||||
 | 
			
		||||
            if (parts.Length == 2)
 | 
			
		||||
            {
 | 
			
		||||
                var prefix = parts[0].Trim().ToLowerInvariant();
 | 
			
		||||
                if (prefix is "l" or "left")
 | 
			
		||||
                {
 | 
			
		||||
                    hasOrientation = true;
 | 
			
		||||
                    left = true;
 | 
			
		||||
                }
 | 
			
		||||
                else if (prefix is "r" or "right")
 | 
			
		||||
                {
 | 
			
		||||
                    hasOrientation = true;
 | 
			
		||||
                    left = false;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                hashPart = parts[1].Trim();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!TryDecodeHash(hashPart, out var hash))
 | 
			
		||||
            {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            node = new ProofPathNode(hasOrientation, left, hash);
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorEntry
 | 
			
		||||
        {
 | 
			
		||||
            RekorUuid = entry.RekorUuid,
 | 
			
		||||
            Artifact = entry.Artifact,
 | 
			
		||||
            BundleSha256 = entry.BundleSha256,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            Proof = proof,
 | 
			
		||||
            Log = entry.Log,
 | 
			
		||||
            CreatedAt = entry.CreatedAt,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            SignerIdentity = entry.SignerIdentity
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(options.Url))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new RekorBackend
 | 
			
		||||
        {
 | 
			
		||||
            Name = name,
 | 
			
		||||
            Url = new Uri(options.Url, UriKind.Absolute),
 | 
			
		||||
            ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
 | 
			
		||||
            PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
 | 
			
		||||
            MaxAttempts = options.MaxAttempts
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal static class RekorProofResponseExtensions
 | 
			
		||||
{
 | 
			
		||||
    public static AttestorEntry.ProofDescriptor ToProofDescriptor(this RekorProofResponse response)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorEntry.ProofDescriptor
 | 
			
		||||
        {
 | 
			
		||||
            Checkpoint = response.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Origin = response.Checkpoint.Origin,
 | 
			
		||||
                Size = response.Checkpoint.Size,
 | 
			
		||||
                RootHash = response.Checkpoint.RootHash,
 | 
			
		||||
                Timestamp = response.Checkpoint.Timestamp
 | 
			
		||||
            },
 | 
			
		||||
            Inclusion = response.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                LeafHash = response.Inclusion.LeafHash,
 | 
			
		||||
                Path = response.Inclusion.Path
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user