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:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -24,7 +24,12 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton<AttestorSubmissionValidator>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
|
||||
});
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
|
||||
@@ -76,6 +76,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("signerIdentity")]
|
||||
public SignerIdentityDocument SignerIdentity { get; set; } = new();
|
||||
|
||||
[BsonElement("mirror")]
|
||||
public MirrorDocument? Mirror { get; set; }
|
||||
|
||||
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntryDocument
|
||||
@@ -109,6 +112,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new LogDocument
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
LogId = entry.Log.LogId
|
||||
},
|
||||
@@ -120,7 +124,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +160,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = Log.Backend,
|
||||
Url = Log.Url,
|
||||
LogId = Log.LogId
|
||||
},
|
||||
@@ -166,7 +172,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = Mirror?.ToDomain()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +227,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
|
||||
internal sealed class LogDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = "primary";
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
@@ -241,5 +251,92 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("kid")]
|
||||
public string? KeyId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class MirrorDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[BsonElement("proof")]
|
||||
public ProofDocument? Proof { get; set; }
|
||||
|
||||
[BsonElement("logId")]
|
||||
public string? LogId { get; set; }
|
||||
|
||||
[BsonElement("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
|
||||
{
|
||||
return new MirrorDocument
|
||||
{
|
||||
Backend = mirror.Backend,
|
||||
Url = mirror.Url,
|
||||
Uuid = mirror.Uuid,
|
||||
Index = mirror.Index,
|
||||
Status = mirror.Status,
|
||||
Proof = mirror.Proof is null ? null : new ProofDocument
|
||||
{
|
||||
Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
|
||||
{
|
||||
Origin = mirror.Proof.Checkpoint.Origin,
|
||||
Size = mirror.Proof.Checkpoint.Size,
|
||||
RootHash = mirror.Proof.Checkpoint.RootHash,
|
||||
Timestamp = mirror.Proof.Checkpoint.Timestamp is null
|
||||
? null
|
||||
: BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
|
||||
},
|
||||
Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
|
||||
{
|
||||
LeafHash = mirror.Proof.Inclusion.LeafHash,
|
||||
Path = mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogId = mirror.LogId,
|
||||
Error = mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
public AttestorEntry.LogReplicaDescriptor ToDomain()
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = Backend,
|
||||
Url = Url,
|
||||
Uuid = Uuid,
|
||||
Index = Index,
|
||||
Status = Status,
|
||||
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = Proof.Checkpoint.Origin,
|
||||
Size = Proof.Checkpoint.Size,
|
||||
RootHash = Proof.Checkpoint.RootHash,
|
||||
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
|
||||
},
|
||||
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = Proof.Inclusion.LeafHash,
|
||||
Path = Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogId = LogId,
|
||||
Error = Error
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -58,137 +59,136 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var canonicalBundle = validation.CanonicalBundle;
|
||||
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(dedupeUuid))
|
||||
var preference = NormalizeLogPreference(request.Meta.LogPreference);
|
||||
var requiresPrimary = preference is "primary" or "both";
|
||||
var requiresMirror = preference is "mirror" or "both";
|
||||
|
||||
if (!requiresPrimary && !requiresMirror)
|
||||
{
|
||||
requiresPrimary = true;
|
||||
}
|
||||
|
||||
if (requiresMirror && !_options.Rekor.Mirror.Enabled)
|
||||
{
|
||||
throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
|
||||
}
|
||||
|
||||
var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedupe hit for bundle {BundleSha256} -> {RekorUuid}", request.Meta.BundleSha256, dedupeUuid);
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
|
||||
var existing = await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
return ToResult(existing);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
|
||||
return ToResult(updated);
|
||||
}
|
||||
|
||||
var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
RekorSubmissionResponse submissionResponse;
|
||||
try
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
|
||||
SubmissionOutcome? canonicalOutcome = null;
|
||||
SubmissionOutcome? mirrorOutcome = null;
|
||||
|
||||
if (requiresPrimary)
|
||||
{
|
||||
submissionResponse = await _rekorClient.SubmitAsync(request, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, primaryBackend.Name);
|
||||
throw;
|
||||
canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var proof = submissionResponse.Proof;
|
||||
if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
if (requiresMirror)
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submissionResponse.Uuid, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
canonicalOutcome = mirror;
|
||||
}
|
||||
else
|
||||
{
|
||||
mirrorOutcome = mirror;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submissionResponse.Uuid, primaryBackend.Name);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(mirrorOutcome);
|
||||
}
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw new InvalidOperationException("No Rekor submission outcome was produced.");
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.Meta.Archive)
|
||||
{
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start, System.Diagnostics.Stopwatch.GetTimestamp());
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", submissionResponse.Status ?? "unknown"),
|
||||
new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
_metrics.SubmitLatency.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
await WriteAuditAsync(request, context, entry, submissionResponse, (long)elapsed.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
|
||||
if (mirrorOutcome is not null)
|
||||
{
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ToResult(entry);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorSubmissionResult
|
||||
var result = new AttestorSubmissionResult
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof is null ? null : new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = entry.Proof.Checkpoint.Origin,
|
||||
Size = entry.Proof.Checkpoint.Size,
|
||||
RootHash = entry.Proof.Checkpoint.RootHash,
|
||||
Timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = entry.Proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = entry.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
}
|
||||
Proof = ToResultProof(entry.Proof)
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
result.Mirror = new AttestorSubmissionResult.MirrorLog
|
||||
{
|
||||
Uuid = entry.Mirror.Uuid,
|
||||
Index = entry.Mirror.Index,
|
||||
LogUrl = entry.Mirror.Url,
|
||||
Status = entry.Mirror.Status,
|
||||
Proof = ToResultProof(entry.Mirror.Proof),
|
||||
Error = entry.Mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private AttestorEntry CreateEntry(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorSubmissionResponse submission,
|
||||
RekorProofResponse? proof,
|
||||
SubmissionContext context,
|
||||
byte[] canonicalBundle)
|
||||
SubmissionOutcome canonicalOutcome,
|
||||
SubmissionOutcome? mirrorOutcome)
|
||||
{
|
||||
if (canonicalOutcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
|
||||
}
|
||||
|
||||
var submission = canonicalOutcome.Submission;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = submission.Uuid,
|
||||
@@ -201,24 +201,11 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
},
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Index = submission.Index,
|
||||
Proof = proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Proof = ConvertProof(canonicalOutcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Url = submission.LogUrl ?? string.Empty,
|
||||
Backend = canonicalOutcome.Backend,
|
||||
Url = submission.LogUrl ?? canonicalOutcome.Url,
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = now,
|
||||
@@ -229,28 +216,233 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
Issuer = context.CallerAudience,
|
||||
SubjectAlternativeName = context.CallerSubject,
|
||||
KeyId = context.CallerClientId
|
||||
}
|
||||
},
|
||||
Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeLogPreference(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "primary";
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"primary" => "primary",
|
||||
"mirror" => "mirror",
|
||||
"both" => "both",
|
||||
_ => "primary"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
|
||||
{
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(dedupeUuid))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry> EnsureBackendsAsync(
|
||||
AttestorEntry existing,
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
bool requiresPrimary,
|
||||
bool requiresMirror,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = existing;
|
||||
var updated = false;
|
||||
|
||||
if (requiresPrimary && !IsPrimary(entry))
|
||||
{
|
||||
var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
entry = PromoteToPrimary(entry, outcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (requiresMirror)
|
||||
{
|
||||
var mirrorSatisfied = entry.Mirror is not null
|
||||
&& entry.Mirror.Error is null
|
||||
&& string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(entry.Mirror.Uuid);
|
||||
|
||||
if (!mirrorSatisfied)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
entry = WithMirror(entry, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(failure);
|
||||
entry = WithMirror(entry, failure);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsPrimary(AttestorEntry entry) =>
|
||||
string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<SubmissionOutcome> SubmitToBackendAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
string backendName,
|
||||
AttestorOptions.RekorBackendOptions backendOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = BuildBackend(backendName, backendOptions);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var proof = submission.Proof;
|
||||
if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submission.Uuid, backendName);
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
|
||||
RecordSubmissionMetrics(outcome);
|
||||
return outcome;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSubmissionMetrics(SubmissionOutcome outcome)
|
||||
{
|
||||
var result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "unknown"
|
||||
: "failed";
|
||||
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", result),
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
|
||||
if (outcome.Latency > TimeSpan.Zero)
|
||||
{
|
||||
_metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ArchiveAsync(
|
||||
AttestorEntry entry,
|
||||
byte[] canonicalBundle,
|
||||
RekorProofResponse? proof,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
metadata["mirror.backend"] = entry.Mirror.Backend;
|
||||
metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
|
||||
metadata["mirror.status"] = entry.Mirror.Status;
|
||||
}
|
||||
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAuditAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
AttestorEntry entry,
|
||||
RekorSubmissionResponse submission,
|
||||
long latencyMs,
|
||||
SubmissionOutcome outcome,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (!outcome.IsSuccess && outcome.Error is not null)
|
||||
{
|
||||
metadata["error"] = outcome.Error.Message;
|
||||
}
|
||||
|
||||
var record = new AttestorAuditRecord
|
||||
{
|
||||
Action = "submit",
|
||||
Result = submission.Status ?? "included",
|
||||
RekorUuid = submission.Uuid,
|
||||
Index = submission.Index,
|
||||
Result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
RekorUuid = outcome.IsSuccess
|
||||
? outcome.Submission!.Uuid
|
||||
: string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
|
||||
? entry.RekorUuid
|
||||
: entry.Mirror?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
ArtifactSha256 = request.Meta.Artifact.Sha256,
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Backend = "primary",
|
||||
LatencyMs = latencyMs,
|
||||
Backend = outcome.Backend,
|
||||
LatencyMs = (long)outcome.Latency.TotalMilliseconds,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Caller = new AttestorAuditRecord.CallerDescriptor
|
||||
{
|
||||
@@ -259,12 +451,160 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
ClientId = context.CallerClientId,
|
||||
MtlsThumbprint = context.MtlsThumbprint,
|
||||
Tenant = context.CallerTenant
|
||||
}
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
return _auditSink.WriteAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.IsSuccess
|
||||
? outcome.Submission!.LogUrl ?? outcome.Url
|
||||
: outcome.Url,
|
||||
Uuid = outcome.Submission?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
Status = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
|
||||
Error = outcome.Error?.Message
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = entry.Proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity,
|
||||
Mirror = CreateMirrorDescriptor(outcome)
|
||||
};
|
||||
}
|
||||
|
||||
private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
|
||||
{
|
||||
if (outcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
|
||||
}
|
||||
|
||||
var mirrorDescriptor = existing.Mirror;
|
||||
if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
|
||||
}
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = outcome.Submission.Uuid,
|
||||
Artifact = existing.Artifact,
|
||||
BundleSha256 = existing.BundleSha256,
|
||||
Index = outcome.Submission.Index,
|
||||
Proof = ConvertProof(outcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.Submission.LogUrl ?? outcome.Url,
|
||||
LogId = existing.Log.LogId
|
||||
},
|
||||
CreatedAt = existing.CreatedAt,
|
||||
Status = outcome.Submission.Status ?? "included",
|
||||
SignerIdentity = existing.SignerIdentity,
|
||||
Mirror = mirrorDescriptor
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof,
|
||||
LogId = entry.Log.LogId
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SubmissionOutcome(
|
||||
string Backend,
|
||||
string Url,
|
||||
RekorSubmissionResponse? Submission,
|
||||
RekorProofResponse? Proof,
|
||||
TimeSpan Latency,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => Submission is not null && Error is null;
|
||||
|
||||
public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
|
||||
|
||||
public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +16,7 @@ using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
@@ -21,19 +27,22 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly ILogger<AttestorVerificationService> _logger;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorVerificationService(
|
||||
IAttestorEntryRepository repository,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationService> logger)
|
||||
ILogger<AttestorVerificationService> logger,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_canonicalizer = canonicalizer;
|
||||
_rekorClient = rekorClient;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -67,11 +76,25 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("Bundle hash does not match stored canonical hash.");
|
||||
issues.Add("bundle_hash_mismatch");
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
issues.Add("bundle_payload_invalid_base64");
|
||||
}
|
||||
else
|
||||
{
|
||||
var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
|
||||
VerifySignatures(entry, request.Bundle, preAuth, issues);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
|
||||
}
|
||||
|
||||
if (request.RefreshProof || entry.Proof is null)
|
||||
@@ -94,8 +117,12 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}
|
||||
|
||||
VerifyMerkleProof(entry, issues);
|
||||
|
||||
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
|
||||
|
||||
return new AttestorVerificationResult
|
||||
{
|
||||
Ok = ok,
|
||||
@@ -204,6 +231,472 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
: entry;
|
||||
}
|
||||
|
||||
private void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
if (mode == "kms")
|
||||
{
|
||||
if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
|
||||
{
|
||||
issues.Add("signature_invalid_kms");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == "keyless")
|
||||
{
|
||||
VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
|
||||
return;
|
||||
}
|
||||
|
||||
issues.Add(string.IsNullOrEmpty(mode)
|
||||
? "signer_mode_unknown"
|
||||
: $"signer_mode_unsupported:{mode}");
|
||||
}
|
||||
|
||||
private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
|
||||
{
|
||||
issues.Add("kms_key_missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
signatures.Add(signatureBytes);
|
||||
}
|
||||
|
||||
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
||||
{
|
||||
if (!TryDecodeSecret(secret, out var secretBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var computed = hmac.ComputeHash(preAuthEncoding);
|
||||
|
||||
foreach (var signatureBytes in signatures)
|
||||
{
|
||||
if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (bundle.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add("certificate_chain_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
foreach (var pem in bundle.CertificateChain)
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPem(pem));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
issues.Add("certificate_chain_invalid");
|
||||
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
var leafCertificate = certificates[0];
|
||||
|
||||
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(rootPath))
|
||||
{
|
||||
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chain.Build(leafCertificate))
|
||||
{
|
||||
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
|
||||
.Trim(';');
|
||||
issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
|
||||
{
|
||||
var sans = GetSubjectAlternativeNames(leafCertificate);
|
||||
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add("certificate_san_untrusted");
|
||||
}
|
||||
}
|
||||
|
||||
var signatureVerified = false;
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
|
||||
{
|
||||
signatureVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureVerified)
|
||||
{
|
||||
issues.Add("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
using (ecdsa)
|
||||
{
|
||||
return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
}
|
||||
|
||||
var rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
using (rsa)
|
||||
{
|
||||
return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var formatted = extension.Format(true);
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
|
||||
{
|
||||
if (entry.Proof is null)
|
||||
{
|
||||
issues.Add("proof_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
|
||||
{
|
||||
issues.Add("bundle_hash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion is null)
|
||||
{
|
||||
issues.Add("proof_inclusion_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion.LeafHash is not null)
|
||||
{
|
||||
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var current = bundleHash;
|
||||
|
||||
if (entry.Proof.Inclusion.Path.Count > 0)
|
||||
{
|
||||
var nodes = new List<ProofPathNode>();
|
||||
foreach (var element in entry.Proof.Inclusion.Path)
|
||||
{
|
||||
if (!ProofPathNode.TryParse(element, out var node))
|
||||
{
|
||||
issues.Add("proof_path_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.HasOrientation)
|
||||
{
|
||||
issues.Add("proof_path_orientation_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
current = node.Left
|
||||
? HashInternal(node.Hash, current)
|
||||
: HashInternal(current, node.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Proof.Checkpoint is null)
|
||||
{
|
||||
issues.Add("checkpoint_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
|
||||
{
|
||||
issues.Add("checkpoint_root_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
|
||||
{
|
||||
issues.Add("proof_root_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private static bool TryDecodeSecret(string value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeBase64(value[7..], out bytes);
|
||||
}
|
||||
|
||||
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeHex(value[4..], out bytes);
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeHex(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHex(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHash(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (TryDecodeHex(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly struct ProofPathNode
|
||||
{
|
||||
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
|
||||
{
|
||||
HasOrientation = hasOrientation;
|
||||
Left = left;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public bool HasOrientation { get; }
|
||||
|
||||
public bool Left { get; }
|
||||
|
||||
public byte[] Hash { get; }
|
||||
|
||||
public static bool TryParse(string value, out ProofPathNode node)
|
||||
{
|
||||
node = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(':', 2);
|
||||
bool hasOrientation = false;
|
||||
bool left = false;
|
||||
string hashPart = trimmed;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var prefix = parts[0].Trim().ToLowerInvariant();
|
||||
if (prefix is "l" or "left")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = true;
|
||||
}
|
||||
else if (prefix is "r" or "right")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = false;
|
||||
}
|
||||
|
||||
hashPart = parts[1].Trim();
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(hashPart, out var hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = new ProofPathNode(hasOrientation, left, hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
return new AttestorEntry
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user