Add Authority Advisory AI and API Lifecycle Configuration

- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings.
- Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations.
- Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration.
- Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options.
- Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations.
- Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client.
- Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Bulk;
internal sealed class BulkVerificationWorker : BackgroundService
{
private readonly IBulkVerificationJobStore _jobStore;
private readonly IAttestorVerificationService _verificationService;
private readonly AttestorMetrics _metrics;
private readonly AttestorOptions _options;
private readonly ILogger<BulkVerificationWorker> _logger;
private readonly TimeProvider _timeProvider;
public BulkVerificationWorker(
IBulkVerificationJobStore jobStore,
IAttestorVerificationService verificationService,
AttestorMetrics metrics,
IOptions<AttestorOptions> options,
TimeProvider timeProvider,
ILogger<BulkVerificationWorker> logger)
{
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
_verificationService = verificationService ?? throw new ArgumentNullException(nameof(verificationService));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var pollDelay = TimeSpan.FromSeconds(Math.Max(1, _options.BulkVerification.WorkerPollSeconds));
while (!stoppingToken.IsCancellationRequested)
{
try
{
var job = await _jobStore.TryAcquireAsync(stoppingToken).ConfigureAwait(false);
if (job is null)
{
await Task.Delay(pollDelay, stoppingToken).ConfigureAwait(false);
continue;
}
await ProcessJobAsync(job, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Bulk verification worker loop failed.");
await Task.Delay(pollDelay, stoppingToken).ConfigureAwait(false);
}
}
}
internal async Task ProcessJobAsync(BulkVerificationJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
_logger.LogInformation("Processing bulk verification job {JobId} with {ItemCount} items.", job.Id, job.Items.Count);
job.StartedAt ??= _timeProvider.GetUtcNow();
if (!await PersistAsync(job, cancellationToken).ConfigureAwait(false))
{
_logger.LogWarning("Failed to persist initial state for job {JobId}.", job.Id);
}
var itemDelay = _options.BulkVerification.ItemDelayMilliseconds > 0
? TimeSpan.FromMilliseconds(_options.BulkVerification.ItemDelayMilliseconds)
: TimeSpan.Zero;
foreach (var item in job.Items.OrderBy(i => i.Index))
{
cancellationToken.ThrowIfCancellationRequested();
if (item.Status is not BulkVerificationItemStatus.Pending)
{
continue;
}
await ExecuteItemAsync(job, item, cancellationToken).ConfigureAwait(false);
if (itemDelay > TimeSpan.Zero)
{
try
{
await Task.Delay(itemDelay, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
}
}
job.CompletedAt = _timeProvider.GetUtcNow();
job.Status = job.FailureReason is null ? BulkVerificationJobStatus.Completed : BulkVerificationJobStatus.Failed;
if (!await PersistAsync(job, cancellationToken).ConfigureAwait(false))
{
_logger.LogWarning("Failed to persist completion state for job {JobId}.", job.Id);
}
var durationSeconds = (job.CompletedAt - job.StartedAt)?.TotalSeconds ?? 0;
var statusTag = job.Status == BulkVerificationJobStatus.Completed && job.FailedCount == 0 ? "succeeded" :
job.Status == BulkVerificationJobStatus.Completed ? "completed_with_failures" : "failed";
_metrics.BulkJobsTotal.Add(1, new KeyValuePair<string, object?>("status", statusTag));
_metrics.BulkJobDuration.Record(durationSeconds, new KeyValuePair<string, object?>("status", statusTag));
_logger.LogInformation("Finished bulk verification job {JobId}. Ran {Processed} items (success: {Success}, failed: {Failed}).",
job.Id, job.ProcessedCount, job.SucceededCount, job.FailedCount);
}
private async Task ExecuteItemAsync(BulkVerificationJob job, BulkVerificationJobItem item, CancellationToken cancellationToken)
{
item.Status = BulkVerificationItemStatus.Running;
item.StartedAt = _timeProvider.GetUtcNow();
await PersistAsync(job, cancellationToken).ConfigureAwait(false);
var statusTag = "failed";
try
{
var request = new AttestorVerificationRequest
{
Uuid = item.Request.Uuid,
ArtifactSha256 = item.Request.ArtifactSha256,
Subject = item.Request.Subject,
EnvelopeId = item.Request.EnvelopeId,
PolicyVersion = item.Request.PolicyVersion,
RefreshProof = item.Request.RefreshProof
};
var result = await _verificationService.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
item.Result = result;
item.CompletedAt = _timeProvider.GetUtcNow();
item.Status = result.Ok ? BulkVerificationItemStatus.Succeeded : BulkVerificationItemStatus.Failed;
statusTag = item.Status == BulkVerificationItemStatus.Succeeded ? "succeeded" : "verification_failed";
job.ProcessedCount++;
if (item.Status == BulkVerificationItemStatus.Succeeded)
{
job.SucceededCount++;
}
else
{
job.FailedCount++;
}
}
catch (AttestorVerificationException verificationEx)
{
item.CompletedAt = _timeProvider.GetUtcNow();
item.Status = BulkVerificationItemStatus.Failed;
item.Error = $"{verificationEx.Code}:{verificationEx.Message}";
job.ProcessedCount++;
job.FailedCount++;
job.FailureReason ??= "item_failure";
statusTag = "verification_error";
}
catch (Exception ex)
{
item.CompletedAt = _timeProvider.GetUtcNow();
item.Status = BulkVerificationItemStatus.Failed;
item.Error = ex.Message;
job.ProcessedCount++;
job.FailedCount++;
job.FailureReason ??= "worker_exception";
_logger.LogError(ex, "Bulk verification item {ItemIndex} failed for job {JobId}.", item.Index, job.Id);
statusTag = "exception";
}
if (!await PersistAsync(job, cancellationToken).ConfigureAwait(false))
{
_logger.LogWarning("Failed to persist progress for job {JobId} item {ItemIndex}.", job.Id, item.Index);
}
_metrics.BulkItemsTotal.Add(1, new KeyValuePair<string, object?>("status", statusTag));
}
private async Task<bool> PersistAsync(BulkVerificationJob job, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 3; attempt++)
{
if (await _jobStore.TryUpdateAsync(job, cancellationToken).ConfigureAwait(false))
{
return true;
}
var refreshed = await _jobStore.GetAsync(job.Id, cancellationToken).ConfigureAwait(false);
if (refreshed is null)
{
return false;
}
Synchronize(job, refreshed);
}
return false;
}
private static void Synchronize(BulkVerificationJob target, BulkVerificationJob source)
{
target.Version = source.Version;
target.Status = source.Status;
target.CreatedAt = source.CreatedAt;
target.StartedAt = source.StartedAt;
target.CompletedAt = source.CompletedAt;
target.ProcessedCount = source.ProcessedCount;
target.SucceededCount = source.SucceededCount;
target.FailedCount = source.FailedCount;
target.FailureReason = source.FailureReason;
var sourceItems = source.Items.ToDictionary(i => i.Index);
foreach (var item in target.Items)
{
if (sourceItems.TryGetValue(item.Index, out var updated))
{
item.Status = updated.Status;
item.StartedAt = updated.StartedAt;
item.CompletedAt = updated.CompletedAt;
item.Result = updated.Result;
item.Error = updated.Error;
}
}
}
}

View File

@@ -0,0 +1,343 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Bulk;
internal sealed class MongoBulkVerificationJobStore : IBulkVerificationJobStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IMongoCollection<JobDocument> _collection;
public MongoBulkVerificationJobStore(IMongoCollection<JobDocument> collection)
{
_collection = collection ?? throw new ArgumentNullException(nameof(collection));
}
public async Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
job.Version = 0;
var document = JobDocument.FromDomain(job, SerializerOptions);
await _collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
job.Version = document.Version;
return job;
}
public async Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(jobId))
{
return null;
}
var filter = Builders<JobDocument>.Filter.Eq(doc => doc.Id, jobId);
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain(SerializerOptions);
}
public async Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default)
{
var filter = Builders<JobDocument>.Filter.Eq(doc => doc.Status, BulkVerificationJobStatus.Queued);
var update = Builders<JobDocument>.Update
.Set(doc => doc.Status, BulkVerificationJobStatus.Running)
.Set(doc => doc.StartedAt, DateTimeOffset.UtcNow.UtcDateTime)
.Inc(doc => doc.Version, 1);
var options = new FindOneAndUpdateOptions<JobDocument>
{
Sort = Builders<JobDocument>.Sort.Ascending(doc => doc.CreatedAt),
ReturnDocument = ReturnDocument.After
};
var document = await _collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false);
return document?.ToDomain(SerializerOptions);
}
public async Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
var currentVersion = job.Version;
var replacement = JobDocument.FromDomain(job, SerializerOptions);
replacement.Version = currentVersion + 1;
var filter = Builders<JobDocument>.Filter.Where(doc => doc.Id == job.Id && doc.Version == currentVersion);
var result = await _collection.ReplaceOneAsync(filter, replacement, cancellationToken: cancellationToken).ConfigureAwait(false);
if (result.ModifiedCount == 0)
{
return false;
}
job.Version = replacement.Version;
return true;
}
public async Task<int> CountQueuedAsync(CancellationToken cancellationToken = default)
{
var filter = Builders<JobDocument>.Filter.Eq(doc => doc.Status, BulkVerificationJobStatus.Queued);
var count = await _collection.CountDocumentsAsync(filter, cancellationToken: cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(count);
}
internal sealed class JobDocument
{
[BsonId]
[BsonElement("_id")]
public string Id { get; set; } = string.Empty;
[BsonElement("version")]
public int Version { get; set; }
[BsonElement("status")]
[BsonRepresentation(BsonType.String)]
public BulkVerificationJobStatus Status { get; set; }
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; }
[BsonElement("startedAt")]
[BsonIgnoreIfNull]
public DateTime? StartedAt { get; set; }
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTime? CompletedAt { get; set; }
[BsonElement("context")]
public JobContextDocument Context { get; set; } = new();
[BsonElement("items")]
public List<JobItemDocument> Items { get; set; } = new();
[BsonElement("processed")]
public int ProcessedCount { get; set; }
[BsonElement("succeeded")]
public int SucceededCount { get; set; }
[BsonElement("failed")]
public int FailedCount { get; set; }
[BsonElement("failureReason")]
[BsonIgnoreIfNull]
public string? FailureReason { get; set; }
public static JobDocument FromDomain(BulkVerificationJob job, JsonSerializerOptions serializerOptions)
{
return new JobDocument
{
Id = job.Id,
Version = job.Version,
Status = job.Status,
CreatedAt = job.CreatedAt.UtcDateTime,
StartedAt = job.StartedAt?.UtcDateTime,
CompletedAt = job.CompletedAt?.UtcDateTime,
Context = JobContextDocument.FromDomain(job.Context),
Items = JobItemDocument.FromDomain(job.Items, serializerOptions),
ProcessedCount = job.ProcessedCount,
SucceededCount = job.SucceededCount,
FailedCount = job.FailedCount,
FailureReason = job.FailureReason
};
}
public BulkVerificationJob ToDomain(JsonSerializerOptions serializerOptions)
{
return new BulkVerificationJob
{
Id = Id,
Version = Version,
Status = Status,
CreatedAt = DateTime.SpecifyKind(CreatedAt, DateTimeKind.Utc),
StartedAt = StartedAt is null ? null : DateTime.SpecifyKind(StartedAt.Value, DateTimeKind.Utc),
CompletedAt = CompletedAt is null ? null : DateTime.SpecifyKind(CompletedAt.Value, DateTimeKind.Utc),
Context = Context.ToDomain(),
Items = JobItemDocument.ToDomain(Items, serializerOptions),
ProcessedCount = ProcessedCount,
SucceededCount = SucceededCount,
FailedCount = FailedCount,
FailureReason = FailureReason
};
}
}
internal sealed class JobContextDocument
{
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("requestedBy")]
[BsonIgnoreIfNull]
public string? RequestedBy { get; set; }
[BsonElement("clientId")]
[BsonIgnoreIfNull]
public string? ClientId { get; set; }
[BsonElement("scopes")]
public List<string> Scopes { get; set; } = new();
public static JobContextDocument FromDomain(BulkVerificationJobContext context)
{
return new JobContextDocument
{
Tenant = context.Tenant,
RequestedBy = context.RequestedBy,
ClientId = context.ClientId,
Scopes = new List<string>(context.Scopes)
};
}
public BulkVerificationJobContext ToDomain()
{
return new BulkVerificationJobContext
{
Tenant = Tenant,
RequestedBy = RequestedBy,
ClientId = ClientId,
Scopes = new List<string>(Scopes ?? new List<string>())
};
}
}
internal sealed class JobItemDocument
{
[BsonElement("index")]
public int Index { get; set; }
[BsonElement("request")]
public ItemRequestDocument Request { get; set; } = new();
[BsonElement("status")]
[BsonRepresentation(BsonType.String)]
public BulkVerificationItemStatus Status { get; set; }
[BsonElement("startedAt")]
[BsonIgnoreIfNull]
public DateTime? StartedAt { get; set; }
[BsonElement("completedAt")]
[BsonIgnoreIfNull]
public DateTime? CompletedAt { get; set; }
[BsonElement("result")]
[BsonIgnoreIfNull]
public string? ResultJson { get; set; }
[BsonElement("error")]
[BsonIgnoreIfNull]
public string? Error { get; set; }
public static List<JobItemDocument> FromDomain(IEnumerable<BulkVerificationJobItem> items, JsonSerializerOptions serializerOptions)
{
var list = new List<JobItemDocument>();
foreach (var item in items)
{
list.Add(new JobItemDocument
{
Index = item.Index,
Request = ItemRequestDocument.FromDomain(item.Request),
Status = item.Status,
StartedAt = item.StartedAt?.UtcDateTime,
CompletedAt = item.CompletedAt?.UtcDateTime,
ResultJson = item.Result is null ? null : JsonSerializer.Serialize(item.Result, serializerOptions),
Error = item.Error
});
}
return list;
}
public static IList<BulkVerificationJobItem> ToDomain(IEnumerable<JobItemDocument> documents, JsonSerializerOptions serializerOptions)
{
var list = new List<BulkVerificationJobItem>();
foreach (var document in documents)
{
AttestorVerificationResult? result = null;
if (!string.IsNullOrWhiteSpace(document.ResultJson))
{
result = JsonSerializer.Deserialize<AttestorVerificationResult>(document.ResultJson, serializerOptions);
}
list.Add(new BulkVerificationJobItem
{
Index = document.Index,
Request = document.Request.ToDomain(),
Status = document.Status,
StartedAt = document.StartedAt is null ? null : DateTime.SpecifyKind(document.StartedAt.Value, DateTimeKind.Utc),
CompletedAt = document.CompletedAt is null ? null : DateTime.SpecifyKind(document.CompletedAt.Value, DateTimeKind.Utc),
Result = result,
Error = document.Error
});
}
return list;
}
}
internal sealed class ItemRequestDocument
{
[BsonElement("uuid")]
[BsonIgnoreIfNull]
public string? Uuid { get; set; }
[BsonElement("artifactSha256")]
[BsonIgnoreIfNull]
public string? ArtifactSha256 { get; set; }
[BsonElement("subject")]
[BsonIgnoreIfNull]
public string? Subject { get; set; }
[BsonElement("envelopeId")]
[BsonIgnoreIfNull]
public string? EnvelopeId { get; set; }
[BsonElement("policyVersion")]
[BsonIgnoreIfNull]
public string? PolicyVersion { get; set; }
[BsonElement("refreshProof")]
public bool RefreshProof { get; set; }
public static ItemRequestDocument FromDomain(BulkVerificationItemRequest request)
{
return new ItemRequestDocument
{
Uuid = request.Uuid,
ArtifactSha256 = request.ArtifactSha256,
Subject = request.Subject,
EnvelopeId = request.EnvelopeId,
PolicyVersion = request.PolicyVersion,
RefreshProof = request.RefreshProof
};
}
public BulkVerificationItemRequest ToDomain()
{
return new BulkVerificationItemRequest
{
Uuid = Uuid,
ArtifactSha256 = ArtifactSha256,
Subject = Subject,
EnvelopeId = EnvelopeId,
PolicyVersion = PolicyVersion,
RefreshProof = RefreshProof
};
}
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Offline;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Infrastructure.Storage;
namespace StellaOps.Attestor.Infrastructure.Offline;
internal sealed class AttestorBundleService : IAttestorBundleService
{
private readonly IAttestorEntryRepository _repository;
private readonly IAttestorArchiveStore _archiveStore;
private readonly TimeProvider _timeProvider;
private readonly AttestorOptions _options;
private readonly ILogger<AttestorBundleService> _logger;
public AttestorBundleService(
IAttestorEntryRepository repository,
IAttestorArchiveStore archiveStore,
TimeProvider timeProvider,
IOptions<AttestorOptions> options,
ILogger<AttestorBundleService> logger)
{
_repository = repository;
_archiveStore = archiveStore;
_timeProvider = timeProvider;
_options = options.Value;
_logger = logger;
}
public async Task<AttestorBundlePackage> ExportAsync(AttestorBundleExportRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var (entries, continuationToken) = await ResolveEntriesAsync(request, cancellationToken).ConfigureAwait(false);
var items = new List<AttestorBundleItem>(entries.Count);
foreach (var entry in entries
.OrderBy(e => e.CreatedAt)
.ThenBy(e => e.RekorUuid, StringComparer.Ordinal))
{
var archiveBundle = await _archiveStore.GetBundleAsync(entry.BundleSha256, entry.RekorUuid, cancellationToken).ConfigureAwait(false);
if (archiveBundle is null)
{
_logger.LogWarning("Archive bundle for {Uuid} ({BundleSha}) unavailable; exporting metadata only.", entry.RekorUuid, entry.BundleSha256);
items.Add(new AttestorBundleItem
{
Entry = entry,
CanonicalBundle = string.Empty,
Metadata = new Dictionary<string, string>
{
["archive.missing"] = "true"
}
});
continue;
}
var metadata = archiveBundle.Metadata ?? new Dictionary<string, string>();
if (!metadata.ContainsKey("logUrl"))
{
metadata = new Dictionary<string, string>(metadata)
{
["logUrl"] = entry.Log.Url
};
}
items.Add(new AttestorBundleItem
{
Entry = entry,
CanonicalBundle = Convert.ToBase64String(archiveBundle.CanonicalBundleJson),
Proof = archiveBundle.ProofJson.Length > 0 ? Convert.ToBase64String(archiveBundle.ProofJson) : null,
Metadata = metadata
});
}
return new AttestorBundlePackage
{
Version = AttestorBundleVersions.Current,
GeneratedAt = _timeProvider.GetUtcNow(),
Items = items,
ContinuationToken = continuationToken
};
}
public async Task<AttestorBundleImportResult> ImportAsync(AttestorBundlePackage package, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(package);
if (!_options.S3.Enabled || _archiveStore is NullAttestorArchiveStore)
{
var skippedCount = package.Items?.Count ?? 0;
_logger.LogWarning("Attestor archive store disabled; skipping import for {Count} bundle item(s).", skippedCount);
return new AttestorBundleImportResult
{
Imported = 0,
Updated = 0,
Skipped = skippedCount,
Issues = new[] { "archive_disabled" }
};
}
if (package.Items is null || package.Items.Count == 0)
{
return new AttestorBundleImportResult
{
Imported = 0,
Updated = 0,
Skipped = 0,
Issues = Array.Empty<string>()
};
}
var imported = 0;
var updated = 0;
var skipped = 0;
var issues = new List<string>();
foreach (var item in package.Items)
{
if (item.Entry is null)
{
skipped++;
issues.Add("entry_missing");
continue;
}
if (string.IsNullOrWhiteSpace(item.Entry.RekorUuid))
{
skipped++;
issues.Add("uuid_missing");
continue;
}
if (string.IsNullOrWhiteSpace(item.Entry.BundleSha256))
{
skipped++;
issues.Add($"bundle_sha_missing:{item.Entry.RekorUuid}");
continue;
}
if (string.IsNullOrWhiteSpace(item.CanonicalBundle))
{
skipped++;
issues.Add($"bundle_payload_missing:{item.Entry.RekorUuid}");
continue;
}
byte[] canonicalBytes;
try
{
canonicalBytes = Convert.FromBase64String(item.CanonicalBundle);
}
catch (FormatException)
{
skipped++;
issues.Add($"bundle_payload_invalid_base64:{item.Entry.RekorUuid}");
continue;
}
var computedSha = Convert.ToHexString(SHA256.HashData(canonicalBytes)).ToLowerInvariant();
if (!string.Equals(computedSha, item.Entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
skipped++;
issues.Add($"bundle_hash_mismatch:{item.Entry.RekorUuid}");
continue;
}
byte[] proofBytes = Array.Empty<byte>();
if (!string.IsNullOrEmpty(item.Proof))
{
try
{
proofBytes = Convert.FromBase64String(item.Proof);
}
catch (FormatException)
{
issues.Add($"proof_invalid_base64:{item.Entry.RekorUuid}");
}
}
var archiveBundle = new AttestorArchiveBundle
{
RekorUuid = item.Entry.RekorUuid,
ArtifactSha256 = item.Entry.Artifact.Sha256,
BundleSha256 = item.Entry.BundleSha256,
CanonicalBundleJson = canonicalBytes,
ProofJson = proofBytes,
Metadata = item.Metadata ?? new Dictionary<string, string>()
};
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
var existing = await _repository.GetByUuidAsync(item.Entry.RekorUuid, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
imported++;
}
else
{
updated++;
}
await _repository.SaveAsync(item.Entry, cancellationToken).ConfigureAwait(false);
}
return new AttestorBundleImportResult
{
Imported = imported,
Updated = updated,
Skipped = skipped,
Issues = issues
};
}
private async Task<(List<AttestorEntry> Entries, string? ContinuationToken)> ResolveEntriesAsync(AttestorBundleExportRequest request, CancellationToken cancellationToken)
{
var entries = new List<AttestorEntry>();
if (request.Uuids is { Count: > 0 })
{
foreach (var uuid in request.Uuids.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var entry = await _repository.GetByUuidAsync(uuid, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
_logger.LogWarning("Attestation {Uuid} not found; skipping export entry.", uuid);
continue;
}
entries.Add(entry);
}
return (entries, null);
}
var limit = request.Limit.HasValue
? Math.Clamp(request.Limit.Value, 1, 200)
: 100;
var query = new AttestorEntryQuery
{
Subject = request.Subject,
Type = request.Type,
Issuer = request.Issuer,
Scope = request.Scope,
CreatedAfter = request.CreatedAfter,
CreatedBefore = request.CreatedBefore,
PageSize = limit,
ContinuationToken = request.ContinuationToken
};
var result = await _repository.QueryAsync(query, cancellationToken).ConfigureAwait(false);
if (result.Items.Count == 0)
{
_logger.LogInformation("No attestor entries matched export query.");
}
entries.AddRange(result.Items.Take(limit));
return (entries, result.ContinuationToken);
}
}

View File

@@ -1,21 +1,24 @@
using System;
using Amazon.Runtime;
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Verification;
using Amazon.Runtime;
using Amazon.S3;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Transparency;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Infrastructure.Transparency;
using StellaOps.Attestor.Infrastructure.Verification;
namespace StellaOps.Attestor.Infrastructure;
@@ -23,7 +26,9 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
services.AddMemoryCache();
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
services.AddSingleton(sp =>
{
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
@@ -33,11 +38,34 @@ public static class ServiceCollectionExtensions
services.AddSingleton<AttestorMetrics>();
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
services.AddHttpClient<HttpRekorClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
services.AddHttpClient<HttpRekorClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
services.AddHttpClient<HttpTransparencyWitnessClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
var timeoutMs = options.TransparencyWitness.RequestTimeoutMs;
if (timeoutMs <= 0)
{
timeoutMs = 15_000;
}
client.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
});
services.AddSingleton<ITransparencyWitnessClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
if (!options.TransparencyWitness.Enabled || string.IsNullOrWhiteSpace(options.TransparencyWitness.BaseUrl))
{
return new NullTransparencyWitnessClient();
}
return sp.GetRequiredService<HttpTransparencyWitnessClient>();
});
services.AddSingleton<IMongoClient>(sp =>
{

View File

@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Crypto.Parameters;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Cryptography.Plugin.BouncyCastle;
namespace StellaOps.Attestor.Infrastructure.Signing;
internal sealed class AttestorSigningKeyRegistry : IDisposable
{
private readonly Dictionary<string, SigningKeyEntry> _keys;
private readonly FileKmsClient? _kmsClient;
private readonly ILogger<AttestorSigningKeyRegistry> _logger;
public AttestorSigningKeyRegistry(
IOptions<AttestorOptions> options,
TimeProvider timeProvider,
ILogger<AttestorSigningKeyRegistry> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var attestorOptions = options?.Value ?? new AttestorOptions();
var signingOptions = attestorOptions.Signing ?? new AttestorOptions.SigningOptions();
var providers = new List<ICryptoProvider>();
var providerMap = new Dictionary<string, ICryptoProvider>(StringComparer.OrdinalIgnoreCase);
void RegisterProvider(ICryptoProvider provider)
{
providers.Add(provider);
providerMap[provider.Name] = provider;
}
var defaultProvider = new DefaultCryptoProvider();
RegisterProvider(defaultProvider);
var edProvider = new BouncyCastleEd25519CryptoProvider();
RegisterProvider(edProvider);
KmsCryptoProvider? kmsProvider = null;
if (RequiresKms(signingOptions))
{
var kmsOptions = signingOptions.Kms ?? throw new InvalidOperationException("attestor.signing.kms is required when a signing key declares mode 'kms'.");
if (string.IsNullOrWhiteSpace(kmsOptions.RootPath))
{
throw new InvalidOperationException("attestor.signing.kms.rootPath must be provided when using KMS-backed signing keys.");
}
if (string.IsNullOrWhiteSpace(kmsOptions.Password))
{
throw new InvalidOperationException("attestor.signing.kms.password must be provided when using KMS-backed signing keys.");
}
var fileOptions = new FileKmsOptions
{
RootPath = Path.GetFullPath(kmsOptions.RootPath!),
Password = kmsOptions.Password!,
Algorithm = kmsOptions.Algorithm,
KeyDerivationIterations = kmsOptions.KeyDerivationIterations ?? 600_000
};
_kmsClient = new FileKmsClient(fileOptions);
kmsProvider = new KmsCryptoProvider(_kmsClient);
RegisterProvider(kmsProvider);
}
Registry = new CryptoProviderRegistry(providers, signingOptions.PreferredProviders);
_keys = new Dictionary<string, SigningKeyEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var key in signingOptions.Keys ?? Array.Empty<AttestorOptions.SigningKeyOptions>())
{
if (key is null || !key.Enabled)
{
continue;
}
var entry = CreateEntry(
key,
providerMap,
defaultProvider,
edProvider,
kmsProvider,
_kmsClient,
timeProvider);
if (_keys.ContainsKey(entry.KeyId))
{
throw new InvalidOperationException($"Duplicate signing key id '{entry.KeyId}' configured.");
}
_keys[entry.KeyId] = entry;
_logger.LogInformation("Registered attestor signing key {KeyId} using provider {Provider} and algorithm {Algorithm}.", entry.KeyId, entry.ProviderName, entry.Algorithm);
}
}
public ICryptoProviderRegistry Registry { get; }
public SigningKeyEntry GetRequired(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
throw new AttestorSigningException("key_missing", "Signing key id must be provided.");
}
if (_keys.TryGetValue(keyId, out var entry))
{
return entry;
}
throw new AttestorSigningException("key_not_found", $"Signing key '{keyId}' is not configured.");
}
public void Dispose()
{
_kmsClient?.Dispose();
}
private static bool RequiresKms(AttestorOptions.SigningOptions signingOptions)
=> signingOptions.Keys?.Any(static key =>
string.Equals(key?.Mode, "kms", StringComparison.OrdinalIgnoreCase)) == true;
private SigningKeyEntry CreateEntry(
AttestorOptions.SigningKeyOptions key,
IReadOnlyDictionary<string, ICryptoProvider> providers,
DefaultCryptoProvider defaultProvider,
BouncyCastleEd25519CryptoProvider edProvider,
KmsCryptoProvider? kmsProvider,
FileKmsClient? kmsClient,
TimeProvider timeProvider)
{
var providerName = ResolveProviderName(key);
if (!providers.TryGetValue(providerName, out var provider))
{
throw new InvalidOperationException($"Signing provider '{providerName}' is not registered for key '{key.KeyId}'.");
}
var providerKeyId = string.IsNullOrWhiteSpace(key.ProviderKeyId) ? key.KeyId : key.ProviderKeyId!;
if (string.IsNullOrWhiteSpace(providerKeyId))
{
throw new InvalidOperationException($"Signing key '{key.KeyId}' must specify a provider key identifier.");
}
var now = timeProvider.GetUtcNow();
var normalizedAlgorithm = NormalizeAlgorithm(key.Algorithm ?? string.Empty);
if (string.Equals(providerName, "kms", StringComparison.OrdinalIgnoreCase))
{
if (kmsProvider is null || kmsClient is null)
{
throw new InvalidOperationException($"KMS signing provider is not configured but signing key '{key.KeyId}' requests mode 'kms'.");
}
var versionId = key.KmsVersionId;
if (string.IsNullOrWhiteSpace(versionId))
{
throw new InvalidOperationException($"Signing key '{key.KeyId}' must specify kmsVersionId when using mode 'kms'.");
}
var material = kmsClient.ExportAsync(providerKeyId, versionId, default).GetAwaiter().GetResult();
var parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
D = material.D,
Q = new ECPoint
{
X = material.Qx,
Y = material.Qy
}
};
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["kms.version"] = material.VersionId
};
var signingKey = new CryptoSigningKey(
new CryptoKeyReference(providerKeyId, providerName),
normalizedAlgorithm,
in parameters,
now,
expiresAt: null,
metadata: metadata);
kmsProvider.UpsertSigningKey(signingKey);
}
else if (string.Equals(providerName, "bouncycastle.ed25519", StringComparison.OrdinalIgnoreCase))
{
var privateKeyBytes = LoadPrivateKeyBytes(key);
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKeyBytes, 0);
var publicKeyBytes = privateKeyParameters.GeneratePublicKey().GetEncoded();
var signingKey = new CryptoSigningKey(
new CryptoKeyReference(providerKeyId, providerName),
normalizedAlgorithm,
privateKeyBytes,
now,
publicKey: publicKeyBytes);
edProvider.UpsertSigningKey(signingKey);
}
else
{
var parameters = LoadEcParameters(key);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference(providerKeyId, providerName),
normalizedAlgorithm,
in parameters,
now);
defaultProvider.UpsertSigningKey(signingKey);
}
var mode = string.IsNullOrWhiteSpace(key.Mode)
? (string.Equals(providerName, "kms", StringComparison.OrdinalIgnoreCase) ? "kms" : "keyful")
: key.Mode!;
var certificateChain = key.CertificateChain?.Count > 0
? key.CertificateChain.ToArray()
: Array.Empty<string>();
return new SigningKeyEntry(
key.KeyId,
providerKeyId,
providerName,
normalizedAlgorithm,
mode,
certificateChain);
}
private static string ResolveProviderName(AttestorOptions.SigningKeyOptions key)
{
if (!string.IsNullOrWhiteSpace(key.Provider))
{
return key.Provider!;
}
if (string.Equals(key.Mode, "kms", StringComparison.OrdinalIgnoreCase))
{
return "kms";
}
if (string.Equals(key.Algorithm, SignatureAlgorithms.Ed25519, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key.Algorithm, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase))
{
return "bouncycastle.ed25519";
}
return "default";
}
private static string NormalizeAlgorithm(string algorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
return SignatureAlgorithms.Es256;
}
if (string.Equals(algorithm, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase))
{
return SignatureAlgorithms.Ed25519;
}
return algorithm.ToUpperInvariant();
}
private static byte[] LoadPrivateKeyBytes(AttestorOptions.SigningKeyOptions key)
{
var material = ReadMaterial(key);
return key.MaterialFormat?.ToLowerInvariant() switch
{
"base64" or null => Convert.FromBase64String(material),
"hex" => Convert.FromHexString(material),
_ => throw new InvalidOperationException($"Unsupported materialFormat '{key.MaterialFormat}' for Ed25519 signing key '{key.KeyId}'. Supported formats: base64, hex.")
};
}
private static ECParameters LoadEcParameters(AttestorOptions.SigningKeyOptions key)
{
var material = ReadMaterial(key);
using var ecdsa = ECDsa.Create();
switch (key.MaterialFormat?.ToLowerInvariant())
{
case null:
case "pem":
ecdsa.ImportFromPem(material);
break;
case "base64":
{
var pkcs8 = Convert.FromBase64String(material);
ecdsa.ImportPkcs8PrivateKey(pkcs8, out _);
break;
}
case "hex":
{
var pkcs8 = Convert.FromHexString(material);
ecdsa.ImportPkcs8PrivateKey(pkcs8, out _);
break;
}
default:
throw new InvalidOperationException($"Unsupported materialFormat '{key.MaterialFormat}' for signing key '{key.KeyId}'. Supported formats: pem, base64, hex.");
}
return ecdsa.ExportParameters(true);
}
private static string ReadMaterial(AttestorOptions.SigningKeyOptions key)
{
if (!string.IsNullOrWhiteSpace(key.MaterialPassphrase))
{
throw new InvalidOperationException($"Signing key '{key.KeyId}' specifies a materialPassphrase but encrypted keys are not yet supported.");
}
if (!string.IsNullOrWhiteSpace(key.Material))
{
return key.Material.Trim();
}
if (!string.IsNullOrWhiteSpace(key.MaterialPath))
{
var path = Path.GetFullPath(key.MaterialPath);
if (!File.Exists(path))
{
throw new InvalidOperationException($"Signing key material file '{path}' for key '{key.KeyId}' does not exist.");
}
return File.ReadAllText(path).Trim();
}
throw new InvalidOperationException($"Signing key '{key.KeyId}' must provide either inline material or a materialPath.");
}
internal sealed record SigningKeyEntry(
string KeyId,
string ProviderKeyId,
string ProviderName,
string Algorithm,
string Mode,
IReadOnlyList<string> CertificateChain);
}

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Cryptography;
namespace StellaOps.Attestor.Infrastructure.Signing;
internal sealed class AttestorSigningService : IAttestationSigningService
{
private readonly AttestorSigningKeyRegistry _registry;
private readonly IDsseCanonicalizer _canonicalizer;
private readonly StellaOps.Attestor.Core.Storage.IAttestorAuditSink _auditSink;
private readonly AttestorMetrics _metrics;
private readonly ILogger<AttestorSigningService> _logger;
private readonly TimeProvider _timeProvider;
public AttestorSigningService(
AttestorSigningKeyRegistry registry,
IDsseCanonicalizer canonicalizer,
StellaOps.Attestor.Core.Storage.IAttestorAuditSink auditSink,
AttestorMetrics metrics,
ILogger<AttestorSigningService> logger,
TimeProvider timeProvider)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
_auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<AttestationSignResult> SignAsync(
AttestationSignRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(context);
var stopwatch = Stopwatch.StartNew();
try
{
if (string.IsNullOrWhiteSpace(request.KeyId))
{
throw new AttestorSigningException("key_missing", "Signing key id is required.");
}
if (string.IsNullOrWhiteSpace(request.PayloadType))
{
throw new AttestorSigningException("payload_type_missing", "payloadType must be provided.");
}
if (string.IsNullOrWhiteSpace(request.PayloadBase64))
{
throw new AttestorSigningException("payload_missing", "payload must be provided as base64.");
}
var entry = _registry.GetRequired(request.KeyId);
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(request.PayloadBase64.Trim());
}
catch (FormatException)
{
throw new AttestorSigningException("payload_invalid_base64", "payload must be valid base64.");
}
var normalizedPayload = Convert.ToBase64String(payloadBytes);
var preAuth = DssePreAuthenticationEncoding.Compute(request.PayloadType, payloadBytes);
var signerResolution = _registry.Registry.ResolveSigner(
CryptoCapability.Signing,
entry.Algorithm,
new CryptoKeyReference(entry.ProviderKeyId, entry.ProviderName),
entry.ProviderName);
var signatureBytes = await signerResolution.Signer.SignAsync(preAuth, cancellationToken).ConfigureAwait(false);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
var bundle = BuildBundle(request, entry, normalizedPayload, signatureBase64);
var meta = BuildMeta(request);
var canonicalRequest = new AttestorSubmissionRequest
{
Bundle = bundle,
Meta = meta
};
var canonical = await _canonicalizer.CanonicalizeAsync(canonicalRequest, cancellationToken).ConfigureAwait(false);
meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
RecordSuccessMetrics(entry, elapsedSeconds);
await WriteAuditAsync(context, entry, meta, elapsedSeconds, result: "signed", error: null, cancellationToken).ConfigureAwait(false);
return new AttestationSignResult
{
Bundle = bundle,
Meta = meta,
KeyId = request.KeyId,
Algorithm = entry.Algorithm,
Mode = bundle.Mode,
Provider = entry.ProviderName,
SignedAt = _timeProvider.GetUtcNow()
};
}
catch (AttestorSigningException)
{
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
RecordFailureMetrics(elapsedSeconds);
await WriteAuditAsync(context, null, null, elapsedSeconds, "failed", error: "validation", cancellationToken).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
var elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
RecordFailureMetrics(elapsedSeconds);
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "sign"));
_logger.LogError(ex, "Unexpected error while signing attestation.");
await WriteAuditAsync(context, null, null, elapsedSeconds, "failed", error: "unexpected", cancellationToken).ConfigureAwait(false);
throw new AttestorSigningException("signing_failed", "Signing failed due to an internal error.", ex);
}
}
private static AttestorSubmissionRequest.SubmissionBundle BuildBundle(
AttestationSignRequest request,
AttestorSigningKeyRegistry.SigningKeyEntry entry,
string normalizedPayload,
string signatureBase64)
{
var mode = string.IsNullOrWhiteSpace(request.Mode) ? entry.Mode : request.Mode!;
var certificateChain = new List<string>(entry.CertificateChain.Count + (request.CertificateChain?.Count ?? 0));
certificateChain.AddRange(entry.CertificateChain);
if (request.CertificateChain is not null)
{
foreach (var cert in request.CertificateChain)
{
if (!string.IsNullOrWhiteSpace(cert) &&
!certificateChain.Contains(cert, StringComparer.Ordinal))
{
certificateChain.Add(cert);
}
}
}
var bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = mode,
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = request.PayloadType,
PayloadBase64 = normalizedPayload,
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = request.KeyId,
Signature = signatureBase64
}
}
},
CertificateChain = certificateChain
};
return bundle;
}
private static AttestorSubmissionRequest.SubmissionMeta BuildMeta(AttestationSignRequest request)
{
var artifact = request.Artifact ?? new AttestorSubmissionRequest.ArtifactInfo();
return new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = artifact.Sha256,
Kind = artifact.Kind,
ImageDigest = artifact.ImageDigest,
SubjectUri = artifact.SubjectUri
},
Archive = request.Archive,
LogPreference = string.IsNullOrWhiteSpace(request.LogPreference)
? "primary"
: request.LogPreference.Trim()
};
}
private void RecordSuccessMetrics(AttestorSigningKeyRegistry.SigningKeyEntry entry, double elapsedSeconds)
{
_metrics.SignTotal.Add(1,
new KeyValuePair<string, object?>("result", "success"),
new KeyValuePair<string, object?>("algorithm", entry.Algorithm),
new KeyValuePair<string, object?>("provider", entry.ProviderName));
_metrics.SignLatency.Record(elapsedSeconds,
new KeyValuePair<string, object?>("algorithm", entry.Algorithm),
new KeyValuePair<string, object?>("provider", entry.ProviderName));
}
private void RecordFailureMetrics(double elapsedSeconds)
{
_metrics.SignTotal.Add(1, new KeyValuePair<string, object?>("result", "failure"));
_metrics.SignLatency.Record(elapsedSeconds);
}
private async Task WriteAuditAsync(
SubmissionContext context,
AttestorSigningKeyRegistry.SigningKeyEntry? entry,
AttestorSubmissionRequest.SubmissionMeta? meta,
double elapsedSeconds,
string result,
string? error,
CancellationToken cancellationToken)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (entry is not null)
{
metadata["algorithm"] = entry.Algorithm;
metadata["provider"] = entry.ProviderName;
metadata["mode"] = entry.Mode;
metadata["keyId"] = entry.KeyId;
}
if (!string.IsNullOrWhiteSpace(error))
{
metadata["error"] = error!;
}
var record = new AttestorAuditRecord
{
Action = "sign",
Result = result,
ArtifactSha256 = meta?.Artifact?.Sha256 ?? string.Empty,
BundleSha256 = meta?.BundleSha256 ?? string.Empty,
Backend = entry?.ProviderName ?? string.Empty,
LatencyMs = (long)(elapsedSeconds * 1000),
Timestamp = _timeProvider.GetUtcNow(),
Caller = new AttestorAuditRecord.CallerDescriptor
{
Subject = context.CallerSubject,
Audience = context.CallerAudience,
ClientId = context.CallerClientId,
MtlsThumbprint = context.MtlsThumbprint,
Tenant = context.CallerTenant
},
Metadata = metadata
};
await _auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -1,21 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,56 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class CachingAttestorDedupeStore : IAttestorDedupeStore
{
private readonly IAttestorDedupeStore _cache;
private readonly IAttestorDedupeStore _inner;
private readonly ILogger<CachingAttestorDedupeStore> _logger;
public CachingAttestorDedupeStore(
IAttestorDedupeStore cache,
IAttestorDedupeStore inner,
ILogger<CachingAttestorDedupeStore> logger)
{
_cache = cache;
_inner = inner;
_logger = logger;
}
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
try
{
var cached = await _cache.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(cached))
{
return cached;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Dedupe cache lookup failed for bundle {BundleSha}", bundleSha256);
}
return await _inner.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
}
public async Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
await _inner.SetAsync(bundleSha256, rekorUuid, ttl, cancellationToken).ConfigureAwait(false);
try
{
await _cache.SetAsync(bundleSha256, rekorUuid, ttl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update dedupe cache for bundle {BundleSha}", bundleSha256);
}
}
}

View File

@@ -9,20 +9,36 @@ using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorAuditSink : IAttestorAuditSink
{
private readonly IMongoCollection<AttestorAuditDocument> _collection;
public MongoAttestorAuditSink(IMongoCollection<AttestorAuditDocument> collection)
{
_collection = collection;
}
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
{
var document = AttestorAuditDocument.FromRecord(record);
return _collection.InsertOneAsync(document, cancellationToken: cancellationToken);
}
internal sealed class MongoAttestorAuditSink : IAttestorAuditSink
{
private readonly IMongoCollection<AttestorAuditDocument> _collection;
private static int _indexesInitialized;
public MongoAttestorAuditSink(IMongoCollection<AttestorAuditDocument> collection)
{
_collection = collection;
EnsureIndexes();
}
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
{
var document = AttestorAuditDocument.FromRecord(record);
return _collection.InsertOneAsync(document, cancellationToken: cancellationToken);
}
private void EnsureIndexes()
{
if (Interlocked.Exchange(ref _indexesInitialized, 1) == 1)
{
return;
}
var index = new CreateIndexModel<AttestorAuditDocument>(
Builders<AttestorAuditDocument>.IndexKeys.Descending(x => x.Timestamp),
new CreateIndexOptions { Name = "ts_desc" });
_collection.Indexes.CreateOne(index);
}
internal sealed class AttestorAuditDocument
{

View File

@@ -0,0 +1,111 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorDedupeStore : IAttestorDedupeStore
{
private readonly IMongoCollection<AttestorDedupeDocument> _collection;
private readonly TimeProvider _timeProvider;
private static int _indexesInitialized;
public MongoAttestorDedupeStore(
IMongoCollection<AttestorDedupeDocument> collection,
TimeProvider timeProvider)
{
_collection = collection;
_timeProvider = timeProvider;
EnsureIndexes();
}
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var key = BuildKey(bundleSha256);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var filter = Builders<AttestorDedupeDocument>.Filter.Eq(x => x.Key, key);
var document = await _collection
.Find(filter)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
return null;
}
if (document.TtlAt <= now)
{
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
return null;
}
return document.RekorUuid;
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var expiresAt = now.Add(ttl);
var key = BuildKey(bundleSha256);
var filter = Builders<AttestorDedupeDocument>.Filter.Eq(x => x.Key, key);
var update = Builders<AttestorDedupeDocument>.Update
.SetOnInsert(x => x.Key, key)
.Set(x => x.RekorUuid, rekorUuid)
.Set(x => x.CreatedAt, now)
.Set(x => x.TtlAt, expiresAt);
return _collection.UpdateOneAsync(
filter,
update,
new UpdateOptions { IsUpsert = true },
cancellationToken);
}
private static string BuildKey(string bundleSha256) => $"bundle:{bundleSha256}";
private void EnsureIndexes()
{
if (Interlocked.Exchange(ref _indexesInitialized, 1) == 1)
{
return;
}
var indexes = new[]
{
new CreateIndexModel<AttestorDedupeDocument>(
Builders<AttestorDedupeDocument>.IndexKeys.Ascending(x => x.Key),
new CreateIndexOptions { Unique = true, Name = "dedupe_key_unique" }),
new CreateIndexModel<AttestorDedupeDocument>(
Builders<AttestorDedupeDocument>.IndexKeys.Ascending(x => x.TtlAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "dedupe_ttl" })
};
_collection.Indexes.CreateMany(indexes);
}
[BsonIgnoreExtraElements]
internal sealed class AttestorDedupeDocument
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("key")]
public string Key { get; set; } = string.Empty;
[BsonElement("rekorUuid")]
public string RekorUuid { get; set; } = string.Empty;
[BsonElement("createdAt")]
public DateTime CreatedAt { get; set; }
[BsonElement("ttlAt")]
public DateTime TtlAt { get; set; }
}
}

View File

@@ -1,342 +1,609 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
{
private readonly IMongoCollection<AttestorEntryDocument> _entries;
public MongoAttestorEntryRepository(IMongoCollection<AttestorEntryDocument> entries)
{
_entries = entries;
}
public async Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.BundleSha256, bundleSha256);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, rekorUuid);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Artifact.Sha256, artifactSha256);
var documents = await _entries.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.ConvertAll(static doc => doc.ToDomain());
}
public async Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
{
var document = AttestorEntryDocument.FromDomain(entry);
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, document.Id);
await _entries.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
[BsonIgnoreExtraElements]
internal sealed class AttestorEntryDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("artifact")]
public ArtifactDocument Artifact { get; set; } = new();
[BsonElement("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("proof")]
public ProofDocument? Proof { get; set; }
[BsonElement("log")]
public LogDocument Log { get; set; } = new();
[BsonElement("createdAt")]
public BsonDateTime CreatedAt { get; set; } = BsonDateTime.Create(System.DateTimeOffset.UtcNow);
[BsonElement("status")]
public string Status { get; set; } = "pending";
[BsonElement("signerIdentity")]
public SignerIdentityDocument SignerIdentity { get; set; } = new();
[BsonElement("mirror")]
public MirrorDocument? Mirror { get; set; }
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
{
return new AttestorEntryDocument
{
Id = entry.RekorUuid,
Artifact = new ArtifactDocument
{
Sha256 = entry.Artifact.Sha256,
Kind = entry.Artifact.Kind,
ImageDigest = entry.Artifact.ImageDigest,
SubjectUri = entry.Artifact.SubjectUri
},
BundleSha256 = entry.BundleSha256,
Index = entry.Index,
Proof = entry.Proof is null ? null : new ProofDocument
{
Checkpoint = entry.Proof.Checkpoint is null ? null : new CheckpointDocument
{
Origin = entry.Proof.Checkpoint.Origin,
Size = entry.Proof.Checkpoint.Size,
RootHash = entry.Proof.Checkpoint.RootHash,
Timestamp = entry.Proof.Checkpoint.Timestamp is null
? null
: BsonDateTime.Create(entry.Proof.Checkpoint.Timestamp.Value)
},
Inclusion = entry.Proof.Inclusion is null ? null : new InclusionDocument
{
LeafHash = entry.Proof.Inclusion.LeafHash,
Path = entry.Proof.Inclusion.Path
}
},
Log = new LogDocument
{
Backend = entry.Log.Backend,
Url = entry.Log.Url,
LogId = entry.Log.LogId
},
CreatedAt = BsonDateTime.Create(entry.CreatedAt.UtcDateTime),
Status = entry.Status,
SignerIdentity = new SignerIdentityDocument
{
Mode = entry.SignerIdentity.Mode,
Issuer = entry.SignerIdentity.Issuer,
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
KeyId = entry.SignerIdentity.KeyId
},
Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
};
}
public AttestorEntry ToDomain()
{
return new AttestorEntry
{
RekorUuid = Id,
Artifact = new AttestorEntry.ArtifactDescriptor
{
Sha256 = Artifact.Sha256,
Kind = Artifact.Kind,
ImageDigest = Artifact.ImageDigest,
SubjectUri = Artifact.SubjectUri
},
BundleSha256 = BundleSha256,
Index = Index,
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
{
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
{
Origin = Proof.Checkpoint.Origin,
Size = Proof.Checkpoint.Size,
RootHash = Proof.Checkpoint.RootHash,
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
},
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
{
LeafHash = Proof.Inclusion.LeafHash,
Path = Proof.Inclusion.Path
}
},
Log = new AttestorEntry.LogDescriptor
{
Backend = Log.Backend,
Url = Log.Url,
LogId = Log.LogId
},
CreatedAt = CreatedAt.ToUniversalTime(),
Status = Status,
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
Mode = SignerIdentity.Mode,
Issuer = SignerIdentity.Issuer,
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
KeyId = SignerIdentity.KeyId
},
Mirror = Mirror?.ToDomain()
};
}
internal sealed class ArtifactDocument
{
[BsonElement("sha256")]
public string Sha256 { get; set; } = string.Empty;
[BsonElement("kind")]
public string Kind { get; set; } = string.Empty;
[BsonElement("imageDigest")]
public string? ImageDigest { get; set; }
[BsonElement("subjectUri")]
public string? SubjectUri { get; set; }
}
internal sealed class ProofDocument
{
[BsonElement("checkpoint")]
public CheckpointDocument? Checkpoint { get; set; }
[BsonElement("inclusion")]
public InclusionDocument? Inclusion { get; set; }
}
internal sealed class CheckpointDocument
{
[BsonElement("origin")]
public string? Origin { get; set; }
[BsonElement("size")]
public long Size { get; set; }
[BsonElement("rootHash")]
public string? RootHash { get; set; }
[BsonElement("timestamp")]
public BsonDateTime? Timestamp { get; set; }
}
internal sealed class InclusionDocument
{
[BsonElement("leafHash")]
public string? LeafHash { get; set; }
[BsonElement("path")]
public IReadOnlyList<string> Path { get; set; } = System.Array.Empty<string>();
}
internal sealed class LogDocument
{
[BsonElement("backend")]
public string Backend { get; set; } = "primary";
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("logId")]
public string? LogId { get; set; }
}
internal sealed class SignerIdentityDocument
{
[BsonElement("mode")]
public string Mode { get; set; } = string.Empty;
[BsonElement("issuer")]
public string? Issuer { get; set; }
[BsonElement("san")]
public string? SubjectAlternativeName { get; set; }
[BsonElement("kid")]
public string? KeyId { get; set; }
}
internal sealed class MirrorDocument
{
[BsonElement("backend")]
public string Backend { get; set; } = string.Empty;
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("uuid")]
public string? Uuid { get; set; }
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "pending";
[BsonElement("proof")]
public ProofDocument? Proof { get; set; }
[BsonElement("logId")]
public string? LogId { get; set; }
[BsonElement("error")]
public string? Error { get; set; }
public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
{
return new MirrorDocument
{
Backend = mirror.Backend,
Url = mirror.Url,
Uuid = mirror.Uuid,
Index = mirror.Index,
Status = mirror.Status,
Proof = mirror.Proof is null ? null : new ProofDocument
{
Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
{
Origin = mirror.Proof.Checkpoint.Origin,
Size = mirror.Proof.Checkpoint.Size,
RootHash = mirror.Proof.Checkpoint.RootHash,
Timestamp = mirror.Proof.Checkpoint.Timestamp is null
? null
: BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
},
Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
{
LeafHash = mirror.Proof.Inclusion.LeafHash,
Path = mirror.Proof.Inclusion.Path
}
},
LogId = mirror.LogId,
Error = mirror.Error
};
}
public AttestorEntry.LogReplicaDescriptor ToDomain()
{
return new AttestorEntry.LogReplicaDescriptor
{
Backend = Backend,
Url = Url,
Uuid = Uuid,
Index = Index,
Status = Status,
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
{
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
{
Origin = Proof.Checkpoint.Origin,
Size = Proof.Checkpoint.Size,
RootHash = Proof.Checkpoint.RootHash,
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
},
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
{
LeafHash = Proof.Inclusion.LeafHash,
Path = Proof.Inclusion.Path
}
},
LogId = LogId,
Error = Error
};
}
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
{
private const int DefaultPageSize = 50;
private const int MaxPageSize = 200;
private readonly IMongoCollection<AttestorEntryDocument> _entries;
public MongoAttestorEntryRepository(IMongoCollection<AttestorEntryDocument> entries)
{
_entries = entries ?? throw new ArgumentNullException(nameof(entries));
EnsureIndexes();
}
public async Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.BundleSha256, bundleSha256);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, rekorUuid);
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document?.ToDomain();
}
public async Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
{
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Artifact.Sha256, artifactSha256);
var documents = await _entries.Find(filter)
.Sort(Builders<AttestorEntryDocument>.Sort.Descending(x => x.CreatedAt))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return documents.ConvertAll(static doc => doc.ToDomain());
}
public async Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var document = AttestorEntryDocument.FromDomain(entry);
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, document.Id);
await _entries.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<AttestorEntryQueryResult> QueryAsync(AttestorEntryQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var pageSize = query.PageSize <= 0 ? DefaultPageSize : Math.Min(query.PageSize, MaxPageSize);
var filterBuilder = Builders<AttestorEntryDocument>.Filter;
var filter = filterBuilder.Empty;
if (!string.IsNullOrWhiteSpace(query.Subject))
{
var subject = query.Subject;
var subjectFilter = filterBuilder.Or(
filterBuilder.Eq(x => x.Artifact.Sha256, subject),
filterBuilder.Eq(x => x.Artifact.ImageDigest, subject),
filterBuilder.Eq(x => x.Artifact.SubjectUri, subject));
filter &= subjectFilter;
}
if (!string.IsNullOrWhiteSpace(query.Type))
{
filter &= filterBuilder.Eq(x => x.Artifact.Kind, query.Type);
}
if (!string.IsNullOrWhiteSpace(query.Issuer))
{
filter &= filterBuilder.Eq(x => x.SignerIdentity.SubjectAlternativeName, query.Issuer);
}
if (!string.IsNullOrWhiteSpace(query.Scope))
{
filter &= filterBuilder.Eq(x => x.SignerIdentity.Issuer, query.Scope);
}
if (query.CreatedAfter is { } createdAfter)
{
filter &= filterBuilder.Gte(x => x.CreatedAt, createdAfter.UtcDateTime);
}
if (query.CreatedBefore is { } createdBefore)
{
filter &= filterBuilder.Lte(x => x.CreatedAt, createdBefore.UtcDateTime);
}
if (!string.IsNullOrWhiteSpace(query.ContinuationToken))
{
if (!AttestorEntryContinuationToken.TryParse(query.ContinuationToken, out var cursor))
{
throw new FormatException("Invalid continuation token.");
}
var cursorInstant = cursor.CreatedAt.UtcDateTime;
var continuationFilter = filterBuilder.Or(
filterBuilder.Lt(x => x.CreatedAt, cursorInstant),
filterBuilder.And(
filterBuilder.Eq(x => x.CreatedAt, cursorInstant),
filterBuilder.Gt(x => x.Id, cursor.RekorUuid)));
filter &= continuationFilter;
}
var sort = Builders<AttestorEntryDocument>.Sort
.Descending(x => x.CreatedAt)
.Ascending(x => x.Id);
var documents = await _entries.Find(filter)
.Sort(sort)
.Limit(pageSize + 1)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
string? continuation = null;
if (documents.Count > pageSize)
{
var cursorDocument = documents[pageSize];
var nextCreatedAt = DateTime.SpecifyKind(cursorDocument.CreatedAt, DateTimeKind.Utc);
continuation = AttestorEntryContinuationToken.Encode(new DateTimeOffset(nextCreatedAt), cursorDocument.Id);
documents.RemoveRange(pageSize, documents.Count - pageSize);
}
var items = documents.ConvertAll(static doc => doc.ToDomain());
return new AttestorEntryQueryResult
{
Items = items,
ContinuationToken = continuation
};
}
private void EnsureIndexes()
{
var keys = Builders<AttestorEntryDocument>.IndexKeys;
var models = new[]
{
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.BundleSha256),
new CreateIndexOptions { Name = "bundle_sha_unique", Unique = true }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Descending(x => x.CreatedAt).Ascending(x => x.Id),
new CreateIndexOptions { Name = "created_at_uuid" }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.Artifact.Sha256),
new CreateIndexOptions { Name = "artifact_sha" }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.Artifact.ImageDigest),
new CreateIndexOptions { Name = "artifact_image_digest" }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.Artifact.SubjectUri),
new CreateIndexOptions { Name = "artifact_subject_uri" }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.SignerIdentity.Issuer)
.Ascending(x => x.Artifact.Kind)
.Descending(x => x.CreatedAt)
.Ascending(x => x.Id),
new CreateIndexOptions { Name = "scope_kind_created_at" }),
new CreateIndexModel<AttestorEntryDocument>(
keys.Ascending(x => x.SignerIdentity.SubjectAlternativeName),
new CreateIndexOptions { Name = "issuer_san" })
};
_entries.Indexes.CreateMany(models);
}
[BsonIgnoreExtraElements]
internal sealed class AttestorEntryDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("artifact")]
public ArtifactDocument Artifact { get; set; } = new();
[BsonElement("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[BsonElement("index")]
public long? Index { get; set; }
[BsonElement("proof")]
public ProofDocument? Proof { get; set; }
[BsonElement("witness")]
public WitnessDocument? Witness { get; set; }
[BsonElement("log")]
public LogDocument Log { get; set; } = new();
[BsonElement("createdAt")]
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime CreatedAt { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "pending";
[BsonElement("signer")]
public SignerIdentityDocument SignerIdentity { get; set; } = new();
[BsonElement("mirror")]
public MirrorDocument? Mirror { get; set; }
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
return new AttestorEntryDocument
{
Id = entry.RekorUuid,
Artifact = ArtifactDocument.FromDomain(entry.Artifact),
BundleSha256 = entry.BundleSha256,
Index = entry.Index,
Proof = ProofDocument.FromDomain(entry.Proof),
Witness = WitnessDocument.FromDomain(entry.Witness),
Log = LogDocument.FromDomain(entry.Log),
CreatedAt = entry.CreatedAt.UtcDateTime,
Status = entry.Status,
SignerIdentity = SignerIdentityDocument.FromDomain(entry.SignerIdentity),
Mirror = MirrorDocument.FromDomain(entry.Mirror)
};
}
public AttestorEntry ToDomain()
{
var createdAtUtc = DateTime.SpecifyKind(CreatedAt, DateTimeKind.Utc);
return new AttestorEntry
{
RekorUuid = Id,
Artifact = Artifact.ToDomain(),
BundleSha256 = BundleSha256,
Index = Index,
Proof = Proof?.ToDomain(),
Witness = Witness?.ToDomain(),
Log = Log.ToDomain(),
CreatedAt = new DateTimeOffset(createdAtUtc),
Status = Status,
SignerIdentity = SignerIdentity.ToDomain(),
Mirror = Mirror?.ToDomain()
};
}
}
internal sealed class ArtifactDocument
{
[BsonElement("sha256")]
public string Sha256 { get; set; } = string.Empty;
[BsonElement("kind")]
public string Kind { get; set; } = string.Empty;
[BsonElement("imageDigest")]
public string? ImageDigest { get; set; }
[BsonElement("subjectUri")]
public string? SubjectUri { get; set; }
public static ArtifactDocument FromDomain(AttestorEntry.ArtifactDescriptor artifact)
{
ArgumentNullException.ThrowIfNull(artifact);
return new ArtifactDocument
{
Sha256 = artifact.Sha256,
Kind = artifact.Kind,
ImageDigest = artifact.ImageDigest,
SubjectUri = artifact.SubjectUri
};
}
public AttestorEntry.ArtifactDescriptor ToDomain()
{
return new AttestorEntry.ArtifactDescriptor
{
Sha256 = Sha256,
Kind = Kind,
ImageDigest = ImageDigest,
SubjectUri = SubjectUri
};
}
}
internal sealed class ProofDocument
{
[BsonElement("checkpoint")]
public CheckpointDocument? Checkpoint { get; set; }
[BsonElement("inclusion")]
public InclusionDocument? Inclusion { get; set; }
public static ProofDocument? FromDomain(AttestorEntry.ProofDescriptor? proof)
{
if (proof is null)
{
return null;
}
return new ProofDocument
{
Checkpoint = CheckpointDocument.FromDomain(proof.Checkpoint),
Inclusion = InclusionDocument.FromDomain(proof.Inclusion)
};
}
public AttestorEntry.ProofDescriptor ToDomain()
{
return new AttestorEntry.ProofDescriptor
{
Checkpoint = Checkpoint?.ToDomain(),
Inclusion = Inclusion?.ToDomain()
};
}
}
internal sealed class WitnessDocument
{
[BsonElement("aggregator")]
public string? Aggregator { get; set; }
[BsonElement("status")]
public string Status { get; set; } = "unknown";
[BsonElement("rootHash")]
public string? RootHash { get; set; }
[BsonElement("retrievedAt")]
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime RetrievedAt { get; set; }
[BsonElement("statement")]
public string? Statement { get; set; }
[BsonElement("signature")]
public string? Signature { get; set; }
[BsonElement("keyId")]
public string? KeyId { get; set; }
[BsonElement("error")]
public string? Error { get; set; }
public static WitnessDocument? FromDomain(AttestorEntry.WitnessDescriptor? witness)
{
if (witness is null)
{
return null;
}
return new WitnessDocument
{
Aggregator = witness.Aggregator,
Status = witness.Status,
RootHash = witness.RootHash,
RetrievedAt = witness.RetrievedAt.UtcDateTime,
Statement = witness.Statement,
Signature = witness.Signature,
KeyId = witness.KeyId,
Error = witness.Error
};
}
public AttestorEntry.WitnessDescriptor ToDomain()
{
return new AttestorEntry.WitnessDescriptor
{
Aggregator = Aggregator ?? string.Empty,
Status = string.IsNullOrWhiteSpace(Status) ? "unknown" : Status,
RootHash = RootHash,
RetrievedAt = new DateTimeOffset(DateTime.SpecifyKind(RetrievedAt, DateTimeKind.Utc)),
Statement = Statement,
Signature = Signature,
KeyId = KeyId,
Error = Error
};
}
}
internal sealed class CheckpointDocument
{
[BsonElement("origin")]
public string? Origin { get; set; }
[BsonElement("size")]
public long Size { get; set; }
[BsonElement("rootHash")]
public string? RootHash { get; set; }
[BsonElement("timestamp")]
[BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
public DateTime? Timestamp { get; set; }
public static CheckpointDocument? FromDomain(AttestorEntry.CheckpointDescriptor? checkpoint)
{
if (checkpoint is null)
{
return null;
}
return new CheckpointDocument
{
Origin = checkpoint.Origin,
Size = checkpoint.Size,
RootHash = checkpoint.RootHash,
Timestamp = checkpoint.Timestamp?.UtcDateTime
};
}
public AttestorEntry.CheckpointDescriptor ToDomain()
{
return new AttestorEntry.CheckpointDescriptor
{
Origin = Origin,
Size = Size,
RootHash = RootHash,
Timestamp = Timestamp is null ? null : new DateTimeOffset(DateTime.SpecifyKind(Timestamp.Value, DateTimeKind.Utc))
};
}
}
internal sealed class InclusionDocument
{
[BsonElement("leafHash")]
public string? LeafHash { get; set; }
[BsonElement("path")]
public IReadOnlyList<string> Path { get; set; } = Array.Empty<string>();
public static InclusionDocument? FromDomain(AttestorEntry.InclusionDescriptor? inclusion)
{
if (inclusion is null)
{
return null;
}
return new InclusionDocument
{
LeafHash = inclusion.LeafHash,
Path = inclusion.Path
};
}
public AttestorEntry.InclusionDescriptor ToDomain()
{
return new AttestorEntry.InclusionDescriptor
{
LeafHash = LeafHash,
Path = Path
};
}
}
internal sealed class LogDocument
{
[BsonElement("backend")]
public string Backend { get; set; } = "primary";
[BsonElement("url")]
public string Url { get; set; } = string.Empty;
[BsonElement("logId")]
public string? LogId { get; set; }
public static LogDocument FromDomain(AttestorEntry.LogDescriptor log)
{
ArgumentNullException.ThrowIfNull(log);
return new LogDocument
{
Backend = log.Backend,
Url = log.Url,
LogId = log.LogId
};
}
public AttestorEntry.LogDescriptor ToDomain()
{
return new AttestorEntry.LogDescriptor
{
Backend = Backend,
Url = Url,
LogId = LogId
};
}
}
internal sealed class SignerIdentityDocument
{
[BsonElement("mode")]
public string Mode { get; set; } = string.Empty;
[BsonElement("issuer")]
public string? Issuer { get; set; }
[BsonElement("san")]
public string? SubjectAlternativeName { get; set; }
[BsonElement("kid")]
public string? KeyId { get; set; }
public static SignerIdentityDocument FromDomain(AttestorEntry.SignerIdentityDescriptor signer)
{
ArgumentNullException.ThrowIfNull(signer);
return new SignerIdentityDocument
{
Mode = signer.Mode,
Issuer = signer.Issuer,
SubjectAlternativeName = signer.SubjectAlternativeName,
KeyId = signer.KeyId
};
}
public AttestorEntry.SignerIdentityDescriptor ToDomain()
{
return new AttestorEntry.SignerIdentityDescriptor
{
Mode = Mode,
Issuer = Issuer,
SubjectAlternativeName = SubjectAlternativeName,
KeyId = KeyId
};
}
}
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("witness")]
public WitnessDocument? Witness { get; set; }
[BsonElement("logId")]
public string? LogId { get; set; }
[BsonElement("error")]
public string? Error { get; set; }
public static MirrorDocument? FromDomain(AttestorEntry.LogReplicaDescriptor? mirror)
{
if (mirror is null)
{
return null;
}
return new MirrorDocument
{
Backend = mirror.Backend,
Url = mirror.Url,
Uuid = mirror.Uuid,
Index = mirror.Index,
Status = mirror.Status,
Proof = ProofDocument.FromDomain(mirror.Proof),
Witness = WitnessDocument.FromDomain(mirror.Witness),
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?.ToDomain(),
Witness = Witness?.ToDomain(),
LogId = LogId,
Error = Error
};
}
}
}

View File

@@ -14,9 +14,15 @@ internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
_logger = logger;
}
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
return Task.CompletedTask;
}
}
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
return Task.CompletedTask;
}
public Task<AttestorArchiveBundle?> GetBundleAsync(string bundleSha256, string rekorUuid, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Archive disabled; bundle {BundleSha} ({RekorUuid}) cannot be retrieved", bundleSha256, rekorUuid);
return Task.FromResult<AttestorArchiveBundle?>(null);
}
}

View File

@@ -1,72 +1,182 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposable
{
private readonly IAmazonS3 _s3;
private readonly AttestorOptions.S3Options _options;
private readonly ILogger<S3AttestorArchiveStore> _logger;
private bool _disposed;
public S3AttestorArchiveStore(IAmazonS3 s3, IOptions<AttestorOptions> options, ILogger<S3AttestorArchiveStore> logger)
{
_s3 = s3;
_options = options.Value.S3;
_logger = logger;
}
public async Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_options.Bucket))
{
_logger.LogWarning("S3 archive bucket is not configured; skipping archive for bundle {Bundle}", bundle.BundleSha256);
return;
}
var prefix = _options.Prefix ?? "attest/";
await PutObjectAsync(prefix + "dsse/" + bundle.BundleSha256 + ".json", bundle.CanonicalBundleJson, cancellationToken).ConfigureAwait(false);
if (bundle.ProofJson.Length > 0)
{
await PutObjectAsync(prefix + "proof/" + bundle.RekorUuid + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
}
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(bundle.Metadata);
await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
}
private Task PutObjectAsync(string key, byte[] content, CancellationToken cancellationToken)
{
using var stream = new MemoryStream(content);
var request = new PutObjectRequest
{
BucketName = _options.Bucket,
Key = key,
InputStream = stream,
AutoCloseStream = false
};
return _s3.PutObjectAsync(request, cancellationToken);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_s3.Dispose();
_disposed = true;
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposable
{
private readonly IAmazonS3 _s3;
private readonly AttestorOptions.S3Options _options;
private readonly ILogger<S3AttestorArchiveStore> _logger;
private bool _disposed;
public S3AttestorArchiveStore(IAmazonS3 s3, IOptions<AttestorOptions> options, ILogger<S3AttestorArchiveStore> logger)
{
_s3 = s3;
_options = options.Value.S3;
_logger = logger;
}
public async Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
EnsureNotDisposed();
if (string.IsNullOrWhiteSpace(_options.Bucket))
{
_logger.LogWarning("S3 archive bucket is not configured; skipping archive for bundle {Bundle}", bundle.BundleSha256);
return;
}
var prefix = _options.Prefix ?? "attest/";
await PutObjectAsync(prefix + "dsse/" + bundle.BundleSha256 + ".json", bundle.CanonicalBundleJson, cancellationToken).ConfigureAwait(false);
if (bundle.ProofJson.Length > 0)
{
await PutObjectAsync(prefix + "proof/" + bundle.RekorUuid + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
await PutObjectAsync(prefix + "proof/" + bundle.BundleSha256 + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
}
var metadata = bundle.Metadata is { Count: > 0 }
? new Dictionary<string, string>(bundle.Metadata)
: new Dictionary<string, string>();
metadata["artifact.sha256"] = bundle.ArtifactSha256;
metadata["bundle.sha256"] = bundle.BundleSha256;
metadata["rekor.uuid"] = bundle.RekorUuid;
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(metadata);
await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
await PutObjectAsync(prefix + "meta/" + bundle.BundleSha256 + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
}
public async Task<AttestorArchiveBundle?> GetBundleAsync(string bundleSha256, string rekorUuid, CancellationToken cancellationToken = default)
{
EnsureNotDisposed();
if (string.IsNullOrWhiteSpace(_options.Bucket))
{
_logger.LogWarning("S3 archive bucket is not configured; cannot retrieve bundle {Bundle}", bundleSha256);
return null;
}
var prefix = _options.Prefix ?? "attest/";
var canonical = await TryGetObjectAsync(prefix + "dsse/" + bundleSha256 + ".json", cancellationToken).ConfigureAwait(false);
if (canonical is null || canonical.Length == 0)
{
return null;
}
var proof =
await TryGetObjectAsync(prefix + "proof/" + bundleSha256 + ".json", cancellationToken).ConfigureAwait(false)
?? await TryGetObjectAsync(prefix + "proof/" + rekorUuid + ".json", cancellationToken).ConfigureAwait(false)
?? Array.Empty<byte>();
var metadataBytes =
await TryGetObjectAsync(prefix + "meta/" + bundleSha256 + ".json", cancellationToken).ConfigureAwait(false)
?? await TryGetObjectAsync(prefix + "meta/" + rekorUuid + ".json", cancellationToken).ConfigureAwait(false);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (metadataBytes is { Length: > 0 })
{
try
{
var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(metadataBytes);
if (parsed is not null)
{
foreach (var pair in parsed)
{
if (!string.IsNullOrWhiteSpace(pair.Key))
{
metadata[pair.Key] = pair.Value;
}
}
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize attestor archive metadata for {Bundle}", bundleSha256);
}
}
metadata["rekor.uuid"] = rekorUuid;
metadata["bundle.sha256"] = bundleSha256;
var artifactSha = metadata.TryGetValue("artifact.sha256", out var artifact) ? artifact : string.Empty;
if (!string.IsNullOrWhiteSpace(artifactSha))
{
metadata["artifact.sha256"] = artifactSha;
}
return new AttestorArchiveBundle
{
RekorUuid = rekorUuid,
ArtifactSha256 = artifactSha,
BundleSha256 = bundleSha256,
CanonicalBundleJson = canonical,
ProofJson = proof,
Metadata = metadata
};
}
private async Task PutObjectAsync(string key, byte[] content, CancellationToken cancellationToken)
{
EnsureNotDisposed();
using var stream = new MemoryStream(content, writable: false);
var request = new PutObjectRequest
{
BucketName = _options.Bucket,
Key = key,
InputStream = stream,
AutoCloseStream = false
};
await _s3.PutObjectAsync(request, cancellationToken).ConfigureAwait(false);
}
private async Task<byte[]?> TryGetObjectAsync(string key, CancellationToken cancellationToken)
{
EnsureNotDisposed();
try
{
using var response = await _s3.GetObjectAsync(_options.Bucket, key, cancellationToken).ConfigureAwait(false);
await using var memory = new MemoryStream();
await response.ResponseStream.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
return memory.ToArray();
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("S3 archive object {Key} not found", key);
return null;
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(S3AttestorArchiveStore));
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_s3.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,223 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Transparency;
namespace StellaOps.Attestor.Infrastructure.Transparency;
internal sealed class HttpTransparencyWitnessClient : ITransparencyWitnessClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _client;
private readonly IMemoryCache _cache;
private readonly AttestorOptions _options;
private readonly AttestorMetrics _metrics;
private readonly AttestorActivitySource _activitySource;
private readonly TimeProvider _timeProvider;
private readonly ILogger<HttpTransparencyWitnessClient> _logger;
public HttpTransparencyWitnessClient(
HttpClient client,
IMemoryCache cache,
IOptions<AttestorOptions> options,
AttestorMetrics metrics,
AttestorActivitySource activitySource,
TimeProvider timeProvider,
ILogger<HttpTransparencyWitnessClient> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TransparencyWitnessObservation?> GetObservationAsync(TransparencyWitnessRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var witnessOptions = _options.TransparencyWitness;
if (!witnessOptions.Enabled || string.IsNullOrWhiteSpace(witnessOptions.BaseUrl))
{
return null;
}
var cacheKey = BuildCacheKey(request);
if (_cache.TryGetValue(cacheKey, out TransparencyWitnessObservation? cached) && cached is not null)
{
return cached;
}
var aggregatorId = witnessOptions.AggregatorId ?? request.Backend;
using var activity = _activitySource.StartWitnessFetch(aggregatorId);
var stopwatch = Stopwatch.StartNew();
try
{
var requestUri = BuildRequestUri(request, witnessOptions.BaseUrl);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
if (!string.IsNullOrWhiteSpace(witnessOptions.ApiKey))
{
httpRequest.Headers.Add("X-API-Key", witnessOptions.ApiKey);
}
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (witnessOptions.RequestTimeoutMs > 0)
{
linkedCts.CancelAfter(TimeSpan.FromMilliseconds(witnessOptions.RequestTimeoutMs));
}
var response = await _client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token).ConfigureAwait(false);
stopwatch.Stop();
RecordWitnessMetrics(aggregatorId, response.IsSuccessStatusCode ? "ok" : "error", stopwatch.Elapsed.TotalSeconds);
if (!response.IsSuccessStatusCode)
{
activity?.SetStatus(ActivityStatusCode.Error, response.ReasonPhrase);
return CacheAndReturn(cacheKey, BuildErrorObservation(aggregatorId, request.CheckpointRootHash, "http_" + ((int)response.StatusCode).ToString()));
}
await using var stream = await response.Content.ReadAsStreamAsync(linkedCts.Token).ConfigureAwait(false);
var payload = await JsonSerializer.DeserializeAsync<WitnessResponse>(stream, SerializerOptions, linkedCts.Token).ConfigureAwait(false);
if (payload is null)
{
return CacheAndReturn(cacheKey, BuildErrorObservation(aggregatorId, request.CheckpointRootHash, "response_empty"));
}
var observation = MapObservation(payload, aggregatorId, request.CheckpointRootHash);
return CacheAndReturn(cacheKey, observation);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
RecordWitnessMetrics(aggregatorId, "error", stopwatch.Elapsed.TotalSeconds);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogWarning(ex, "Failed to fetch transparency witness data for {Uuid} ({Backend})", request.Uuid, request.Backend);
return CacheAndReturn(cacheKey, BuildErrorObservation(aggregatorId, request.CheckpointRootHash, ex.GetType().Name, ex.Message));
}
}
private TransparencyWitnessObservation CacheAndReturn(string key, TransparencyWitnessObservation observation)
{
var ttlSeconds = Math.Max(1, _options.TransparencyWitness.CacheTtlSeconds);
var entryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(ttlSeconds)
};
_cache.Set(key, observation, entryOptions);
return observation;
}
private static string BuildCacheKey(TransparencyWitnessRequest request)
{
var root = string.IsNullOrWhiteSpace(request.CheckpointRootHash) ? string.Empty : request.CheckpointRootHash;
return "witness::" + request.Backend + "::" + request.Uuid + "::" + root;
}
private static Uri BuildRequestUri(TransparencyWitnessRequest request, string baseUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Transparency witness base URL is invalid.");
}
var basePath = baseUri.AbsolutePath.TrimEnd('/');
var escapedUuid = Uri.EscapeDataString(request.Uuid);
var builder = new UriBuilder(baseUri)
{
Path = (basePath.Length == 0 ? string.Empty : basePath) + "/v1/witness/" + escapedUuid
};
var query = "backend=" + Uri.EscapeDataString(request.Backend) + "&logUrl=" + Uri.EscapeDataString(request.BackendUrl.ToString());
if (!string.IsNullOrWhiteSpace(request.CheckpointRootHash))
{
query += "&rootHash=" + Uri.EscapeDataString(request.CheckpointRootHash);
}
builder.Query = query;
return builder.Uri;
}
private void RecordWitnessMetrics(string aggregatorId, string result, double latencySeconds)
{
_metrics.WitnessFetchTotal.Add(1,
new KeyValuePair<string, object?>(AttestorTelemetryTags.WitnessAggregator, aggregatorId),
new KeyValuePair<string, object?>(AttestorTelemetryTags.Result, result));
_metrics.WitnessFetchLatency.Record(latencySeconds,
new KeyValuePair<string, object?>(AttestorTelemetryTags.WitnessAggregator, aggregatorId));
}
private TransparencyWitnessObservation MapObservation(WitnessResponse payload, string aggregatorId, string? requestedRoot)
{
var status = string.IsNullOrWhiteSpace(payload.Status) ? "unknown" : payload.Status!;
var root = string.IsNullOrWhiteSpace(payload.RootHash) ? requestedRoot : payload.RootHash;
var timestamp = payload.Timestamp ?? _timeProvider.GetUtcNow();
return new TransparencyWitnessObservation
{
Aggregator = string.IsNullOrWhiteSpace(payload.Aggregator) ? aggregatorId : payload.Aggregator!,
Status = status,
RootHash = root,
RetrievedAt = timestamp,
Statement = payload.Statement,
Signature = payload.Signature?.Value,
KeyId = payload.Signature?.KeyId,
Error = payload.Error
};
}
private TransparencyWitnessObservation BuildErrorObservation(string aggregatorId, string? requestedRoot, string errorCode, string? details = null)
{
return new TransparencyWitnessObservation
{
Aggregator = aggregatorId,
Status = errorCode,
RootHash = requestedRoot,
RetrievedAt = _timeProvider.GetUtcNow(),
Error = details
};
}
private sealed class WitnessResponse
{
public string? Aggregator { get; set; }
public string? Status { get; set; }
public string? RootHash { get; set; }
public string? Statement { get; set; }
public WitnessSignature? Signature { get; set; }
public DateTimeOffset? Timestamp { get; set; }
public string? Error { get; set; }
}
private sealed class WitnessSignature
{
public string? KeyId { get; set; }
public string? Value { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Transparency;
namespace StellaOps.Attestor.Infrastructure.Transparency;
internal sealed class NullTransparencyWitnessClient : ITransparencyWitnessClient
{
public Task<TransparencyWitnessObservation?> GetObservationAsync(TransparencyWitnessRequest request, CancellationToken cancellationToken = default)
{
return Task.FromResult<TransparencyWitnessObservation?>(null);
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Verification;
internal sealed class CachedAttestorVerificationService : IAttestorVerificationService
{
private readonly IAttestorVerificationService _inner;
private readonly IAttestorVerificationCache _cache;
private readonly AttestorMetrics _metrics;
private readonly ILogger<CachedAttestorVerificationService> _logger;
private readonly bool _cacheEnabled;
public CachedAttestorVerificationService(
IAttestorVerificationService inner,
IAttestorVerificationCache cache,
AttestorMetrics metrics,
IOptions<AttestorOptions> options,
ILogger<CachedAttestorVerificationService> logger)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
_cacheEnabled = options.Value.Cache.Verification.Enabled;
}
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
CacheDescriptor? cacheDescriptor = null;
if (_cacheEnabled && TryBuildDescriptor(request, out var descriptor))
{
cacheDescriptor = descriptor;
_metrics.VerifyCacheLookupTotal.Add(1, new KeyValuePair<string, object?>("status", "lookup"));
var cached = await _cache.GetAsync(descriptor.Subject, descriptor.EnvelopeId, descriptor.PolicyVersion, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
_metrics.VerifyCacheHitTotal.Add(1, new KeyValuePair<string, object?>("status", "hit"));
_logger.LogDebug("Verification cache hit for subject {Subject} envelope {Envelope} policy {Policy}.", descriptor.Subject, descriptor.EnvelopeId, descriptor.PolicyVersion);
return cached;
}
_logger.LogDebug("Verification cache miss for subject {Subject} envelope {Envelope} policy {Policy}.", descriptor.Subject, descriptor.EnvelopeId, descriptor.PolicyVersion);
}
var result = await _inner.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
if (cacheDescriptor is not null)
{
await _cache.SetAsync(cacheDescriptor.Value.Subject, cacheDescriptor.Value.EnvelopeId, cacheDescriptor.Value.PolicyVersion, result, cancellationToken).ConfigureAwait(false);
}
return result;
}
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default) =>
_inner.GetEntryAsync(rekorUuid, refreshProof, cancellationToken);
private static bool TryBuildDescriptor(AttestorVerificationRequest request, out CacheDescriptor descriptor)
{
descriptor = default;
if (request.RefreshProof)
{
return false;
}
var subject = Normalize(request.Subject);
var envelopeId = Normalize(request.EnvelopeId);
var policyVersion = Normalize(request.PolicyVersion);
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(envelopeId) || string.IsNullOrEmpty(policyVersion))
{
return false;
}
descriptor = new CacheDescriptor(subject, envelopeId, policyVersion);
return true;
}
private static string Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
private readonly record struct CacheDescriptor(string Subject, string EnvelopeId, string PolicyVersion);
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Verification;
internal sealed class InMemoryAttestorVerificationCache : IAttestorVerificationCache
{
private readonly IMemoryCache _cache;
private readonly ILogger<InMemoryAttestorVerificationCache> _logger;
private readonly TimeSpan _ttl;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _subjectIndex = new(StringComparer.Ordinal);
public InMemoryAttestorVerificationCache(
IMemoryCache cache,
IOptions<AttestorOptions> options,
ILogger<InMemoryAttestorVerificationCache> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(options);
var ttlSeconds = Math.Max(1, options.Value.Cache.Verification.TtlSeconds);
_ttl = TimeSpan.FromSeconds(ttlSeconds);
}
public Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(subject, envelopeId, policyVersion);
if (_cache.TryGetValue(cacheKey, out AttestorVerificationResult? result) && result is not null)
{
return Task.FromResult<AttestorVerificationResult?>(result);
}
return Task.FromResult<AttestorVerificationResult?>(null);
}
public Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
var cacheKey = BuildCacheKey(subject, envelopeId, policyVersion);
var subjectKey = Normalize(subject);
var entryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _ttl,
Size = 1
};
entryOptions.RegisterPostEvictionCallback((key, _, _, state) =>
{
if (key is string removedKey && state is string removedSubject)
{
RemoveFromIndex(removedSubject, removedKey);
}
}, subjectKey);
_cache.Set(cacheKey, result, entryOptions);
var keys = _subjectIndex.GetOrAdd(subjectKey, _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
keys[cacheKey] = 0;
_logger.LogDebug("Cached verification result for subject {Subject} envelope {Envelope} policy {Policy} with TTL {TtlSeconds}s.",
subjectKey, Normalize(envelopeId), Normalize(policyVersion), _ttl.TotalSeconds.ToString(CultureInfo.InvariantCulture));
return Task.CompletedTask;
}
public Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(subject))
{
return Task.CompletedTask;
}
var subjectKey = Normalize(subject);
if (!_subjectIndex.TryRemove(subjectKey, out var keys))
{
return Task.CompletedTask;
}
foreach (var entry in keys.Keys)
{
_cache.Remove(entry);
}
_logger.LogDebug("Invalidated verification cache for subject {Subject}.", subjectKey);
return Task.CompletedTask;
}
private static string BuildCacheKey(string subject, string envelopeId, string policyVersion) =>
string.Concat(Normalize(subject), "|", Normalize(envelopeId), "|", Normalize(policyVersion));
private static string Normalize(string value) => (value ?? string.Empty).Trim();
private void RemoveFromIndex(string subject, string cacheKey)
{
if (_subjectIndex.TryGetValue(subject, out var keys))
{
keys.TryRemove(cacheKey, out _);
if (keys.IsEmpty)
{
_subjectIndex.TryRemove(subject, out _);
}
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Verification;
internal sealed class NoOpAttestorVerificationCache : IAttestorVerificationCache
{
public Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default)
=> Task.FromResult<AttestorVerificationResult?>(null);
public Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}