Add channel test providers for Email, Slack, Teams, and Webhook
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
		@@ -24,7 +24,12 @@ public static class ServiceCollectionExtensions
 | 
			
		||||
    public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
 | 
			
		||||
    {
 | 
			
		||||
        services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
 | 
			
		||||
        services.AddSingleton<AttestorSubmissionValidator>();
 | 
			
		||||
        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>();
 | 
			
		||||
 
 | 
			
		||||
@@ -76,6 +76,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
        [BsonElement("signerIdentity")]
 | 
			
		||||
        public SignerIdentityDocument SignerIdentity { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
        [BsonElement("mirror")]
 | 
			
		||||
        public MirrorDocument? Mirror { get; set; }
 | 
			
		||||
 | 
			
		||||
        public static AttestorEntryDocument FromDomain(AttestorEntry entry)
 | 
			
		||||
        {
 | 
			
		||||
            return new AttestorEntryDocument
 | 
			
		||||
@@ -109,6 +112,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
                },
 | 
			
		||||
                Log = new LogDocument
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = entry.Log.Backend,
 | 
			
		||||
                    Url = entry.Log.Url,
 | 
			
		||||
                    LogId = entry.Log.LogId
 | 
			
		||||
                },
 | 
			
		||||
@@ -120,7 +124,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
                    Issuer = entry.SignerIdentity.Issuer,
 | 
			
		||||
                    SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
 | 
			
		||||
                    KeyId = entry.SignerIdentity.KeyId
 | 
			
		||||
                }
 | 
			
		||||
                },
 | 
			
		||||
                Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -155,6 +160,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
                },
 | 
			
		||||
                Log = new AttestorEntry.LogDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    Backend = Log.Backend,
 | 
			
		||||
                    Url = Log.Url,
 | 
			
		||||
                    LogId = Log.LogId
 | 
			
		||||
                },
 | 
			
		||||
@@ -166,7 +172,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
                    Issuer = SignerIdentity.Issuer,
 | 
			
		||||
                    SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
 | 
			
		||||
                    KeyId = SignerIdentity.KeyId
 | 
			
		||||
                }
 | 
			
		||||
                },
 | 
			
		||||
                Mirror = Mirror?.ToDomain()
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -220,6 +227,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
 | 
			
		||||
        internal sealed class LogDocument
 | 
			
		||||
        {
 | 
			
		||||
            [BsonElement("backend")]
 | 
			
		||||
            public string Backend { get; set; } = "primary";
 | 
			
		||||
 | 
			
		||||
            [BsonElement("url")]
 | 
			
		||||
            public string Url { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
@@ -241,5 +251,92 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
 | 
			
		||||
            [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
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Diagnostics;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
@@ -58,137 +59,136 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        CancellationToken cancellationToken = default)
 | 
			
		||||
    {
 | 
			
		||||
        var start = System.Diagnostics.Stopwatch.GetTimestamp();
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(request);
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(context);
 | 
			
		||||
 | 
			
		||||
        var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var canonicalBundle = validation.CanonicalBundle;
 | 
			
		||||
 | 
			
		||||
        var dedupeUuid = await _dedupeStore.TryGetExistingAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!string.IsNullOrEmpty(dedupeUuid))
 | 
			
		||||
        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)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogInformation("Dedupe hit for bundle {BundleSha256} -> {RekorUuid}", request.Meta.BundleSha256, dedupeUuid);
 | 
			
		||||
            _metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
 | 
			
		||||
            var existing = await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
                ?? await _repository.GetByBundleShaAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (existing is not null)
 | 
			
		||||
            {
 | 
			
		||||
                _metrics.SubmitTotal.Add(1,
 | 
			
		||||
                    new KeyValuePair<string, object?>("result", "dedupe"),
 | 
			
		||||
                    new KeyValuePair<string, object?>("backend", "cache"));
 | 
			
		||||
                return ToResult(existing);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            _metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
 | 
			
		||||
            var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return ToResult(updated);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
 | 
			
		||||
        RekorSubmissionResponse submissionResponse;
 | 
			
		||||
        try
 | 
			
		||||
        _metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
 | 
			
		||||
 | 
			
		||||
        SubmissionOutcome? canonicalOutcome = null;
 | 
			
		||||
        SubmissionOutcome? mirrorOutcome = null;
 | 
			
		||||
 | 
			
		||||
        if (requiresPrimary)
 | 
			
		||||
        {
 | 
			
		||||
            submissionResponse = await _rekorClient.SubmitAsync(request, primaryBackend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit"));
 | 
			
		||||
            _logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, primaryBackend.Name);
 | 
			
		||||
            throw;
 | 
			
		||||
            canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var proof = submissionResponse.Proof;
 | 
			
		||||
        if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        if (requiresMirror)
 | 
			
		||||
        {
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                proof = await _rekorClient.GetProofAsync(submissionResponse.Uuid, primaryBackend, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                _metrics.ProofFetchTotal.Add(1,
 | 
			
		||||
                    new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
 | 
			
		||||
                var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (canonicalOutcome is null)
 | 
			
		||||
                {
 | 
			
		||||
                    canonicalOutcome = mirror;
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    mirrorOutcome = mirror;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            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}", submissionResponse.Uuid, primaryBackend.Name);
 | 
			
		||||
                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);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
 | 
			
		||||
        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)
 | 
			
		||||
        {
 | 
			
		||||
            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 = new Dictionary<string, string>
 | 
			
		||||
                {
 | 
			
		||||
                    ["logUrl"] = entry.Log.Url,
 | 
			
		||||
                    ["status"] = entry.Status
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            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"));
 | 
			
		||||
            }
 | 
			
		||||
            await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start, System.Diagnostics.Stopwatch.GetTimestamp());
 | 
			
		||||
        _metrics.SubmitTotal.Add(1,
 | 
			
		||||
            new KeyValuePair<string, object?>("result", submissionResponse.Status ?? "unknown"),
 | 
			
		||||
            new KeyValuePair<string, object?>("backend", primaryBackend.Name));
 | 
			
		||||
        _metrics.SubmitLatency.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("backend", primaryBackend.Name));
 | 
			
		||||
        await WriteAuditAsync(request, context, entry, submissionResponse, (long)elapsed.TotalMilliseconds, 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)
 | 
			
		||||
    {
 | 
			
		||||
        return new AttestorSubmissionResult
 | 
			
		||||
        var result = new AttestorSubmissionResult
 | 
			
		||||
        {
 | 
			
		||||
            Uuid = entry.RekorUuid,
 | 
			
		||||
            Index = entry.Index,
 | 
			
		||||
            LogUrl = entry.Log.Url,
 | 
			
		||||
            Status = entry.Status,
 | 
			
		||||
            Proof = entry.Proof is null ? null : new AttestorSubmissionResult.RekorProof
 | 
			
		||||
            {
 | 
			
		||||
                Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
 | 
			
		||||
                {
 | 
			
		||||
                    Origin = entry.Proof.Checkpoint.Origin,
 | 
			
		||||
                    Size = entry.Proof.Checkpoint.Size,
 | 
			
		||||
                    RootHash = entry.Proof.Checkpoint.RootHash,
 | 
			
		||||
                    Timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
 | 
			
		||||
                },
 | 
			
		||||
                Inclusion = entry.Proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
 | 
			
		||||
                {
 | 
			
		||||
                    LeafHash = entry.Proof.Inclusion.LeafHash,
 | 
			
		||||
                    Path = entry.Proof.Inclusion.Path
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            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,
 | 
			
		||||
        RekorSubmissionResponse submission,
 | 
			
		||||
        RekorProofResponse? proof,
 | 
			
		||||
        SubmissionContext context,
 | 
			
		||||
        byte[] canonicalBundle)
 | 
			
		||||
        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,
 | 
			
		||||
@@ -201,24 +201,11 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
 | 
			
		||||
            },
 | 
			
		||||
            BundleSha256 = request.Meta.BundleSha256,
 | 
			
		||||
            Index = submission.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
 | 
			
		||||
                },
 | 
			
		||||
                Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
 | 
			
		||||
                {
 | 
			
		||||
                    LeafHash = proof.Inclusion.LeafHash,
 | 
			
		||||
                    Path = proof.Inclusion.Path
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            Proof = ConvertProof(canonicalOutcome.Proof),
 | 
			
		||||
            Log = new AttestorEntry.LogDescriptor
 | 
			
		||||
            {
 | 
			
		||||
                Url = submission.LogUrl ?? string.Empty,
 | 
			
		||||
                Backend = canonicalOutcome.Backend,
 | 
			
		||||
                Url = submission.LogUrl ?? canonicalOutcome.Url,
 | 
			
		||||
                LogId = null
 | 
			
		||||
            },
 | 
			
		||||
            CreatedAt = now,
 | 
			
		||||
@@ -229,28 +216,233 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
 | 
			
		||||
                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,
 | 
			
		||||
        RekorSubmissionResponse submission,
 | 
			
		||||
        long latencyMs,
 | 
			
		||||
        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 = submission.Status ?? "included",
 | 
			
		||||
            RekorUuid = submission.Uuid,
 | 
			
		||||
            Index = submission.Index,
 | 
			
		||||
            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 = "primary",
 | 
			
		||||
            LatencyMs = latencyMs,
 | 
			
		||||
            Backend = outcome.Backend,
 | 
			
		||||
            LatencyMs = (long)outcome.Latency.TotalMilliseconds,
 | 
			
		||||
            Timestamp = _timeProvider.GetUtcNow(),
 | 
			
		||||
            Caller = new AttestorAuditRecord.CallerDescriptor
 | 
			
		||||
            {
 | 
			
		||||
@@ -259,12 +451,160 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
 | 
			
		||||
                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))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,12 @@
 | 
			
		||||
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;
 | 
			
		||||
@@ -10,7 +16,7 @@ using StellaOps.Attestor.Core.Rekor;
 | 
			
		||||
using StellaOps.Attestor.Core.Storage;
 | 
			
		||||
using StellaOps.Attestor.Core.Submission;
 | 
			
		||||
using StellaOps.Attestor.Core.Verification;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using StellaOps.Attestor.Core.Observability;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Attestor.Infrastructure.Verification;
 | 
			
		||||
 | 
			
		||||
@@ -21,19 +27,22 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
 | 
			
		||||
    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)
 | 
			
		||||
        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)
 | 
			
		||||
@@ -67,11 +76,25 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
 | 
			
		||||
                }
 | 
			
		||||
            }, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
 | 
			
		||||
            var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
 | 
			
		||||
            if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                issues.Add("Bundle hash does not match stored canonical hash.");
 | 
			
		||||
                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)
 | 
			
		||||
@@ -94,8 +117,12 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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,
 | 
			
		||||
@@ -204,6 +231,472 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
 | 
			
		||||
            : 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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user