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

@@ -37,6 +37,10 @@ public sealed class AttestorOptions
public bool RequireClientCertificate { get; set; } = true;
public string? CaBundle { get; set; }
public IList<string> AllowedSubjects { get; set; } = new List<string>();
public IList<string> AllowedThumbprints { get; set; } = new List<string>();
}
public sealed class AuthorityOptions

View File

@@ -26,6 +26,8 @@ public sealed class AttestorEntry
public SignerIdentityDescriptor SignerIdentity { get; init; } = new();
public LogReplicaDescriptor? Mirror { get; init; }
public sealed class ArtifactDescriptor
{
public string Sha256 { get; init; } = string.Empty;
@@ -64,6 +66,8 @@ public sealed class AttestorEntry
public sealed class LogDescriptor
{
public string Backend { get; init; } = "primary";
public string Url { get; init; } = string.Empty;
public string? LogId { get; init; }
@@ -79,4 +83,23 @@ public sealed class AttestorEntry
public string? KeyId { get; init; }
}
public sealed class LogReplicaDescriptor
{
public string Backend { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
public string? Uuid { get; init; }
public long? Index { get; init; }
public string Status { get; init; } = "pending";
public ProofDescriptor? Proof { get; init; }
public string? LogId { get; init; }
public string? Error { get; init; }
}
}

View File

@@ -24,6 +24,9 @@ public sealed class AttestorSubmissionResult
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
[JsonPropertyName("mirror")]
public MirrorLog? Mirror { get; set; }
public sealed class RekorProof
{
[JsonPropertyName("checkpoint")]
@@ -56,4 +59,25 @@ public sealed class AttestorSubmissionResult
[JsonPropertyName("path")]
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
}
public sealed class MirrorLog
{
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "pending";
[JsonPropertyName("proof")]
public RekorProof? Proof { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
}
}

View File

@@ -12,10 +12,14 @@ public sealed class AttestorSubmissionValidator
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
private readonly IDsseCanonicalizer _canonicalizer;
private readonly HashSet<string> _allowedModes;
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer)
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer, IEnumerable<string>? allowedModes = null)
{
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
_allowedModes = allowedModes is null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(allowedModes, StringComparer.OrdinalIgnoreCase);
}
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
@@ -47,6 +51,11 @@ public sealed class AttestorSubmissionValidator
throw new AttestorValidationException("signature_missing", "At least one DSSE signature is required.");
}
if (_allowedModes.Count > 0 && !string.IsNullOrWhiteSpace(request.Bundle.Mode) && !_allowedModes.Contains(request.Bundle.Mode))
{
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
}
if (request.Meta is null)
{
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");

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

View File

@@ -80,6 +80,207 @@ public sealed class AttestorSubmissionServiceTests
Assert.Equal(first.Uuid, stored!.RekorUuid);
}
[Fact]
public async Task Validator_ThrowsWhenModeNotAllowed()
{
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
var request = CreateValidRequest(canonicalizer);
request.Bundle.Mode = "keyless";
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
}
[Fact]
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var logger = new NullLogger<AttestorSubmissionService>();
using var metrics = new AttestorMetrics();
var service = new AttestorSubmissionService(
validator,
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
Assert.Equal("mirror_disabled", ex.Code);
}
[Fact]
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var logger = new NullLogger<AttestorSubmissionService>();
using var metrics = new AttestorMetrics();
var service = new AttestorSubmissionService(
validator,
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "both";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Mirror);
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
Assert.Equal("included", result.Mirror.Status);
}
[Fact]
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var logger = new NullLogger<AttestorSubmissionService>();
using var metrics = new AttestorMetrics();
var service = new AttestorSubmissionService(
validator,
repository,
dedupeStore,
rekorClient,
archiveStore,
auditSink,
options,
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal("mirror", stored!.Log.Backend);
Assert.Null(result.Mirror);
}
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
{
var request = new AttestorSubmissionRequest

View File

@@ -1,3 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
@@ -17,6 +19,9 @@ namespace StellaOps.Attestor.Tests;
public sealed class AttestorVerificationServiceTests
{
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
[Fact]
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
{
@@ -35,6 +40,14 @@ public sealed class AttestorVerificationServiceTests
PollIntervalMs = 50,
MaxAttempts = 2
}
},
Security = new AttestorOptions.SecurityOptions
{
SignerIdentity = new AttestorOptions.SignerIdentityOptions
{
Mode = { "kms" },
KmsKeys = { HmacSecretBase64 }
}
}
});
@@ -57,7 +70,7 @@ public sealed class AttestorVerificationServiceTests
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
@@ -73,11 +86,13 @@ public sealed class AttestorVerificationServiceTests
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
new NullLogger<AttestorVerificationService>(),
metrics);
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = response.Uuid
Uuid = response.Uuid,
Bundle = submission.Bundle
});
Assert.True(verifyResult.Ok);
@@ -100,6 +115,14 @@ public sealed class AttestorVerificationServiceTests
PollIntervalMs = 50,
MaxAttempts = 2
}
},
Security = new AttestorOptions.SecurityOptions
{
SignerIdentity = new AttestorOptions.SignerIdentityOptions
{
Mode = { "kms" },
KmsKeys = { HmacSecretBase64 }
}
}
});
@@ -122,7 +145,7 @@ public sealed class AttestorVerificationServiceTests
TimeProvider.System,
metrics);
var submission = CreateSubmissionRequest(canonicalizer);
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
@@ -138,9 +161,10 @@ public sealed class AttestorVerificationServiceTests
canonicalizer,
rekorClient,
options,
new NullLogger<AttestorVerificationService>());
new NullLogger<AttestorVerificationService>(),
metrics);
var tamperedBundle = submission.Bundle;
var tamperedBundle = CloneBundle(submission.Bundle);
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
@@ -150,29 +174,21 @@ public sealed class AttestorVerificationServiceTests
});
Assert.False(result.Ok);
Assert.Contains(result.Issues, issue => issue.Contains("Bundle hash", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
}
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer)
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
{
var payload = Encoding.UTF8.GetBytes("{}");
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Mode = "kms",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
PayloadBase64 = Convert.ToBase64String(payload)
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
@@ -187,8 +203,65 @@ public sealed class AttestorVerificationServiceTests
}
};
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
using (var hmac = new HMACSHA256(hmacSecret))
{
var signature = hmac.ComputeHash(preAuth);
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = "kms-test",
Signature = Convert.ToBase64String(signature)
});
}
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
{
var clone = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = source.Mode,
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = source.Dsse.PayloadType,
PayloadBase64 = source.Dsse.PayloadBase64
}
};
foreach (var certificate in source.CertificateChain)
{
clone.CertificateChain.Add(certificate);
}
foreach (var signature in source.Dsse.Signatures)
{
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
{
KeyId = signature.KeyId,
Signature = signature.Signature
});
}
return clone;
}
private static byte[] ComputePreAuthEncodingForTests(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;
}
}

View File

@@ -1,6 +1,11 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting;
using Serilog;
using Serilog.Events;
using StellaOps.Attestor.Core.Options;
@@ -13,6 +18,7 @@ using OpenTelemetry.Metrics;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Verification;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Serilog.Context;
const string ConfigurationSection = "attestor";
@@ -36,9 +42,45 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton(attestorOptions);
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static (context, _) =>
{
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
return ValueTask.CompletedTask;
};
options.AddPolicy("attestor-submissions", httpContext =>
{
var identity = httpContext.Connection.ClientCertificate?.Thumbprint
?? httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("client_id")?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
var quota = attestorOptions.Quotas.PerCaller;
var tokensPerPeriod = Math.Max(1, quota.Qps);
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
});
});
builder.Services.AddOptions<AttestorOptions>()
.Bind(builder.Configuration.GetSection(ConfigurationSection))
.ValidateOnStart();
@@ -105,6 +147,61 @@ builder.WebHost.ConfigureKestrel(kestrel =>
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
}
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
https.ClientCertificateValidation = (certificate, _, _) =>
{
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
{
return true;
}
if (certificate is null)
{
Log.Warning("Client certificate missing");
return false;
}
if (clientCertificateAuthorities.Count > 0)
{
using var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.NoCheck,
TrustMode = X509ChainTrustMode.CustomRootTrust
}
};
foreach (var authority in clientCertificateAuthorities)
{
chain.ChainPolicy.CustomTrustStore.Add(authority);
}
if (!chain.Build(certificate))
{
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
return false;
}
}
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
return false;
}
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
{
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
return false;
}
return true;
};
});
});
@@ -112,6 +209,22 @@ var app = builder.Build();
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(correlationId))
{
correlationId = Guid.NewGuid().ToString("N");
}
context.Response.Headers["X-Correlation-Id"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next().ConfigureAwait(false);
}
});
app.UseExceptionHandler(static handler =>
{
handler.Run(async context =>
@@ -121,6 +234,8 @@ app.UseExceptionHandler(static handler =>
});
});
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
@@ -156,7 +271,8 @@ app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, H
});
}
})
.RequireAuthorization("attestor:write");
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions");
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
{
@@ -170,6 +286,7 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA
{
uuid = entry.RekorUuid,
index = entry.Index,
backend = entry.Log.Backend,
proof = entry.Proof is null ? null : new
{
checkpoint = entry.Proof.Checkpoint is null ? null : new
@@ -187,6 +304,30 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA
},
logURL = entry.Log.Url,
status = entry.Status,
mirror = entry.Mirror is null ? null : new
{
backend = entry.Mirror.Backend,
uuid = entry.Mirror.Uuid,
index = entry.Mirror.Index,
logURL = entry.Mirror.Url,
status = entry.Mirror.Status,
proof = entry.Mirror.Proof is null ? null : new
{
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
{
origin = entry.Mirror.Proof.Checkpoint.Origin,
size = entry.Mirror.Proof.Checkpoint.Size,
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
},
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
{
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
path = entry.Mirror.Proof.Inclusion.Path
}
},
error = entry.Mirror.Error
},
artifact = new
{
sha256 = entry.Artifact.Sha256,
@@ -232,3 +373,33 @@ static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certif
MtlsThumbprint = certificate.Thumbprint
};
}
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
{
var certificates = new List<X509Certificate2>();
if (string.IsNullOrWhiteSpace(path))
{
return certificates;
}
try
{
if (!File.Exists(path))
{
Log.Warning("Client CA bundle '{Path}' not found", path);
return certificates;
}
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(path);
certificates.AddRange(collection.Cast<X509Certificate2>());
}
catch (Exception ex) when (ex is IOException or CryptographicException)
{
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
}
return certificates;
}

View File

@@ -6,6 +6,5 @@
| ATTESTOR-VERIFY-11-202 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. | ✅ `GET /api/v1/rekor/entries/{uuid}` surfaces cached entries with optional backend refresh and handles not-found/refresh flows.<br>`POST /api/v1/rekor/verify` accepts UUID, bundle, or artifact hash inputs; verifies DSSE signatures, Merkle proofs, and checkpoint anchors.<br>✅ Verification output returns `{ok, uuid, index, logURL, checkedAt}` with failure diagnostics for invalid proofs.<br>✅ Unit/integration tests exercise cache hits, backend refresh, invalid bundle/proof scenarios, and checkpoint trust anchor enforcement. |
| ATTESTOR-OBS-11-203 | DONE (2025-10-19) | Attestor Guild | — | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. | ✅ Structured logs, metrics, and optional traces record submission latency, proof fetch outcomes, verification results, and Rekor error buckets with correlation IDs.<br>✅ mTLS enforcement hardened (peer allowlist, SAN checks, rate limiting) and documented; TLS settings audited for modern ciphers only.<br>✅ Alerting/dashboard pack covers error rates, proof backlog, Redis/Mongo health, and archive job failures; runbook updated.<br>✅ Archive workflow includes retention policy jobs, failure alerts, and periodic verification of stored bundles and proofs. |
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); Attestor Guild tasks moved to DOING for execution.
> Remark (2025-10-19): `/rekor/entries` submission service implemented with Mongo/Redis persistence, optional S3 archival, Rekor HTTP client, and OpenTelemetry metrics; verification APIs (`/rekor/entries/{uuid}`, `/rekor/verify`) added with proof refresh and canonical hash checks. Remaining: integrate real Rekor endpoints in staging and expand failure-mode tests.
> Remark (2025-10-19): Added Rekor mock client + integration harness to unblock attestor verification testing without external connectivity. Follow-up tasks to wire staging Rekor and record retry/error behavior still pending.
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); ATTESTOR-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 tracked as DOING per Wave 0A kickoff.
> Remark (2025-10-19): Dual-log submissions, signature/proof verification, and observability hardening landed; attestor endpoints now rate-limited per client with correlation-ID logging and updated docs/tests.