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:
master
2025-10-19 23:29:34 +03:00
parent a811f7ac47
commit a07f46231b
239 changed files with 17245 additions and 3155 deletions

View File

@@ -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>();

View File

@@ -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
};
}
}
}
}

View File

@@ -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))

View File

@@ -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