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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user