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,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Bulk;
|
||||
|
||||
public sealed class BulkVerificationJob
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public int Version { get; set; }
|
||||
|
||||
public BulkVerificationJobStatus Status { get; set; } = BulkVerificationJobStatus.Queued;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public BulkVerificationJobContext Context { get; set; } = new();
|
||||
|
||||
public IList<BulkVerificationJobItem> Items { get; set; } = new List<BulkVerificationJobItem>();
|
||||
|
||||
public int ProcessedCount { get; set; }
|
||||
|
||||
public int SucceededCount { get; set; }
|
||||
|
||||
public int FailedCount { get; set; }
|
||||
|
||||
public string? FailureReason { get; set; }
|
||||
|
||||
public bool AllCompleted => Items.Count > 0 && Items.All(i => i.Status is BulkVerificationItemStatus.Succeeded or BulkVerificationItemStatus.Failed);
|
||||
}
|
||||
|
||||
public sealed class BulkVerificationJobItem
|
||||
{
|
||||
public int Index { get; set; }
|
||||
|
||||
public BulkVerificationItemRequest Request { get; set; } = new();
|
||||
|
||||
public BulkVerificationItemStatus Status { get; set; } = BulkVerificationItemStatus.Pending;
|
||||
|
||||
public DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public AttestorVerificationResult? Result { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkVerificationItemRequest
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
public string? ArtifactSha256 { get; set; }
|
||||
|
||||
public string? Subject { get; set; }
|
||||
|
||||
public string? EnvelopeId { get; set; }
|
||||
|
||||
public string? PolicyVersion { get; set; }
|
||||
|
||||
public bool RefreshProof { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkVerificationJobContext
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
public string? RequestedBy { get; set; }
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public IList<string> Scopes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public enum BulkVerificationJobStatus
|
||||
{
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum BulkVerificationItemStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Bulk;
|
||||
|
||||
public interface IBulkVerificationJobStore
|
||||
{
|
||||
Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountQueuedAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Observability;
|
||||
|
||||
public sealed class AttestorActivitySource : IDisposable
|
||||
{
|
||||
public const string Name = "StellaOps.Attestor";
|
||||
|
||||
private readonly ActivitySource _source = new(Name);
|
||||
private bool _disposed;
|
||||
|
||||
public Activity? StartVerification(string subject, string issuer, string policy)
|
||||
{
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ AttestorTelemetryTags.Subject, subject },
|
||||
{ AttestorTelemetryTags.Issuer, issuer },
|
||||
{ AttestorTelemetryTags.Policy, policy }
|
||||
};
|
||||
|
||||
return _source.StartActivity("attestor.verify", ActivityKind.Internal, default(ActivityContext), tags);
|
||||
}
|
||||
|
||||
public Activity? StartProofRefresh(string backend, string policy)
|
||||
{
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ "attestor.log.backend", backend },
|
||||
{ AttestorTelemetryTags.Policy, policy }
|
||||
};
|
||||
|
||||
return _source.StartActivity("attestor.verify.refresh_proof", ActivityKind.Internal, default(ActivityContext), tags);
|
||||
}
|
||||
|
||||
public Activity? StartWitnessFetch(string aggregator)
|
||||
{
|
||||
var tags = new ActivityTagsCollection
|
||||
{
|
||||
{ AttestorTelemetryTags.WitnessAggregator, string.IsNullOrWhiteSpace(aggregator) ? "unknown" : aggregator }
|
||||
};
|
||||
|
||||
return _source.StartActivity("attestor.verify.fetch_witness", ActivityKind.Internal, default(ActivityContext), tags);
|
||||
}
|
||||
|
||||
public ActivitySource Source => _source;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_source.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,75 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Observability;
|
||||
|
||||
public sealed class AttestorMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Attestor";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private bool _disposed;
|
||||
|
||||
public AttestorMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName);
|
||||
SubmitTotal = _meter.CreateCounter<long>("attestor.submit_total", description: "Total submission attempts grouped by result and backend.");
|
||||
SubmitLatency = _meter.CreateHistogram<double>("attestor.submit_latency_seconds", unit: "s", description: "Submission latency in seconds per backend.");
|
||||
ProofFetchTotal = _meter.CreateCounter<long>("attestor.proof_fetch_total", description: "Proof fetch attempts grouped by result.");
|
||||
VerifyTotal = _meter.CreateCounter<long>("attestor.verify_total", description: "Verification attempts grouped by result.");
|
||||
DedupeHitsTotal = _meter.CreateCounter<long>("attestor.dedupe_hits_total", description: "Number of dedupe hits by outcome.");
|
||||
ErrorTotal = _meter.CreateCounter<long>("attestor.errors_total", description: "Total errors grouped by type.");
|
||||
}
|
||||
|
||||
public Counter<long> SubmitTotal { get; }
|
||||
|
||||
public Histogram<double> SubmitLatency { get; }
|
||||
|
||||
public Counter<long> ProofFetchTotal { get; }
|
||||
|
||||
public Counter<long> VerifyTotal { get; }
|
||||
|
||||
public Counter<long> DedupeHitsTotal { get; }
|
||||
|
||||
public Counter<long> ErrorTotal { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_meter.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Observability;
|
||||
|
||||
public sealed class AttestorMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.Attestor";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private bool _disposed;
|
||||
|
||||
public AttestorMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName);
|
||||
SubmitTotal = _meter.CreateCounter<long>("attestor.submit_total", description: "Total submission attempts grouped by result and backend.");
|
||||
SubmitLatency = _meter.CreateHistogram<double>("attestor.submit_latency_seconds", unit: "s", description: "Submission latency in seconds per backend.");
|
||||
SignTotal = _meter.CreateCounter<long>("attestor.sign_total", description: "Total signing attempts grouped by result/algorithm/provider.");
|
||||
SignLatency = _meter.CreateHistogram<double>("attestor.sign_latency_seconds", unit: "s", description: "Signing latency in seconds grouped by algorithm/provider.");
|
||||
ProofFetchTotal = _meter.CreateCounter<long>("attestor.proof_fetch_total", description: "Proof fetch attempts grouped by result.");
|
||||
WitnessFetchTotal = _meter.CreateCounter<long>("attestor.witness_fetch_total", description: "Transparency witness fetch attempts grouped by result and aggregator.");
|
||||
WitnessFetchLatency = _meter.CreateHistogram<double>("attestor.witness_fetch_latency_seconds", unit: "s", description: "Transparency witness fetch latency grouped by aggregator.");
|
||||
VerifyTotal = _meter.CreateCounter<long>("attestor.verify_total", description: "Verification attempts grouped by subject, issuer, policy, and result.");
|
||||
VerifyLatency = _meter.CreateHistogram<double>("attestor.verify_latency_seconds", unit: "s", description: "Verification latency in seconds grouped by subject, issuer, policy, and result.");
|
||||
VerifyCacheLookupTotal = _meter.CreateCounter<long>("attestor.verify_cache_lookup_total", description: "Verification cache lookups.");
|
||||
VerifyCacheHitTotal = _meter.CreateCounter<long>("attestor.verify_cache_hit_total", description: "Verification cache hits.");
|
||||
DedupeHitsTotal = _meter.CreateCounter<long>("attestor.dedupe_hits_total", description: "Number of dedupe hits by outcome.");
|
||||
BulkJobsTotal = _meter.CreateCounter<long>("attestor.bulk_jobs_total", description: "Bulk verification jobs processed grouped by status.");
|
||||
BulkItemsTotal = _meter.CreateCounter<long>("attestor.bulk_items_total", description: "Bulk verification items processed grouped by result.");
|
||||
BulkJobDuration = _meter.CreateHistogram<double>("attestor.bulk_job_duration_seconds", unit: "s", description: "Bulk verification job duration in seconds grouped by status.");
|
||||
ErrorTotal = _meter.CreateCounter<long>("attestor.errors_total", description: "Total errors grouped by type.");
|
||||
}
|
||||
|
||||
public Counter<long> SubmitTotal { get; }
|
||||
|
||||
public Histogram<double> SubmitLatency { get; }
|
||||
|
||||
public Counter<long> SignTotal { get; }
|
||||
|
||||
public Histogram<double> SignLatency { get; }
|
||||
|
||||
public Counter<long> ProofFetchTotal { get; }
|
||||
|
||||
public Counter<long> WitnessFetchTotal { get; }
|
||||
|
||||
public Histogram<double> WitnessFetchLatency { get; }
|
||||
|
||||
public Counter<long> VerifyTotal { get; }
|
||||
|
||||
public Histogram<double> VerifyLatency { get; }
|
||||
|
||||
public Counter<long> VerifyCacheLookupTotal { get; }
|
||||
|
||||
public Counter<long> VerifyCacheHitTotal { get; }
|
||||
|
||||
public Counter<long> DedupeHitsTotal { get; }
|
||||
|
||||
public Counter<long> BulkJobsTotal { get; }
|
||||
|
||||
public Counter<long> BulkItemsTotal { get; }
|
||||
|
||||
public Histogram<double> BulkJobDuration { get; }
|
||||
|
||||
public Counter<long> ErrorTotal { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_meter.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Attestor.Core.Observability;
|
||||
|
||||
public static class AttestorTelemetryTags
|
||||
{
|
||||
public const string Subject = "attestor.subject";
|
||||
public const string Issuer = "attestor.issuer";
|
||||
public const string Policy = "attestor.policy";
|
||||
public const string Result = "result";
|
||||
public const string WitnessAggregator = "attestor.witness.aggregator";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Offline;
|
||||
|
||||
public static class AttestorBundleVersions
|
||||
{
|
||||
public const string V1 = "stellaops.attestor.bundle/1";
|
||||
|
||||
public const string Current = V1;
|
||||
}
|
||||
|
||||
public sealed class AttestorBundlePackage
|
||||
{
|
||||
public string Version { get; init; } = AttestorBundleVersions.Current;
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
public IReadOnlyList<AttestorBundleItem> Items { get; init; } = Array.Empty<AttestorBundleItem>();
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestorBundleItem
|
||||
{
|
||||
public AttestorEntry Entry { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Canonical DSSE envelope encoded as base64 (UTF-8 JSON).
|
||||
/// </summary>
|
||||
public string CanonicalBundle { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional Rekor proof payload encoded as base64 (UTF-8 JSON).
|
||||
/// </summary>
|
||||
public string? Proof { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestorBundleExportRequest
|
||||
{
|
||||
public IReadOnlyList<string> Uuids { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? Type { get; init; }
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? Scope { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
public int? Limit { get; init; }
|
||||
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestorBundleImportResult
|
||||
{
|
||||
public int Imported { get; init; }
|
||||
|
||||
public int Updated { get; init; }
|
||||
|
||||
public int Skipped { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Offline;
|
||||
|
||||
public interface IAttestorBundleService
|
||||
{
|
||||
Task<AttestorBundlePackage> ExportAsync(AttestorBundleExportRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestorBundleImportResult> ImportAsync(AttestorBundlePackage package, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,148 +1,301 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the Attestor service.
|
||||
/// </summary>
|
||||
public sealed class AttestorOptions
|
||||
{
|
||||
public string Listen { get; set; } = "https://0.0.0.0:8444";
|
||||
|
||||
public SecurityOptions Security { get; set; } = new();
|
||||
|
||||
public RekorOptions Rekor { get; set; } = new();
|
||||
|
||||
public MongoOptions Mongo { get; set; } = new();
|
||||
|
||||
public RedisOptions Redis { get; set; } = new();
|
||||
|
||||
public S3Options S3 { get; set; } = new();
|
||||
|
||||
public QuotaOptions Quotas { get; set; } = new();
|
||||
|
||||
public TelemetryOptions Telemetry { get; set; } = new();
|
||||
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
public MtlsOptions Mtls { get; set; } = new();
|
||||
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
public SignerIdentityOptions SignerIdentity { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MtlsOptions
|
||||
{
|
||||
public bool RequireClientCertificate { get; set; } = true;
|
||||
|
||||
public string? CaBundle { get; set; }
|
||||
|
||||
public IList<string> AllowedSubjects { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedThumbprints { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
public string? JwksUrl { get; set; }
|
||||
|
||||
public string? RequireSenderConstraint { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> RequiredScopes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class SignerIdentityOptions
|
||||
{
|
||||
public IList<string> Mode { get; set; } = new List<string> { "keyless", "kms" };
|
||||
|
||||
public IList<string> FulcioRoots { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedSans { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> KmsKeys { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class RekorOptions
|
||||
{
|
||||
public RekorBackendOptions Primary { get; set; } = new();
|
||||
|
||||
public RekorMirrorOptions Mirror { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RekorBackendOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public int ProofTimeoutMs { get; set; } = 15_000;
|
||||
|
||||
public int PollIntervalMs { get; set; } = 250;
|
||||
|
||||
public int MaxAttempts { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class RekorMirrorOptions : RekorBackendOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MongoOptions
|
||||
{
|
||||
public string? Uri { get; set; }
|
||||
|
||||
public string Database { get; set; } = "attestor";
|
||||
|
||||
public string EntriesCollection { get; set; } = "entries";
|
||||
|
||||
public string DedupeCollection { get; set; } = "dedupe";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
}
|
||||
|
||||
public sealed class RedisOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? DedupePrefix { get; set; } = "attestor:dedupe:";
|
||||
}
|
||||
|
||||
public sealed class S3Options
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
public string? Prefix { get; set; }
|
||||
|
||||
public string? ObjectLockMode { get; set; }
|
||||
|
||||
public bool UseTls { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class QuotaOptions
|
||||
{
|
||||
public PerCallerQuotaOptions PerCaller { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class PerCallerQuotaOptions
|
||||
{
|
||||
public int Qps { get; set; } = 50;
|
||||
|
||||
public int Burst { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
public bool EnableTracing { get; set; } = false;
|
||||
}
|
||||
}
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the Attestor service.
|
||||
/// </summary>
|
||||
public sealed class AttestorOptions
|
||||
{
|
||||
public string Listen { get; set; } = "https://0.0.0.0:8444";
|
||||
|
||||
public SecurityOptions Security { get; set; } = new();
|
||||
|
||||
public RekorOptions Rekor { get; set; } = new();
|
||||
|
||||
public SigningOptions Signing { get; set; } = new();
|
||||
|
||||
public MongoOptions Mongo { get; set; } = new();
|
||||
|
||||
public RedisOptions Redis { get; set; } = new();
|
||||
|
||||
public S3Options S3 { get; set; } = new();
|
||||
|
||||
public QuotaOptions Quotas { get; set; } = new();
|
||||
|
||||
public BulkVerificationOptions BulkVerification { get; set; } = new();
|
||||
|
||||
public CacheOptions Cache { get; set; } = new();
|
||||
|
||||
public TelemetryOptions Telemetry { get; set; } = new();
|
||||
public TransparencyWitnessOptions TransparencyWitness { get; set; } = new();
|
||||
public VerificationOptions Verification { get; set; } = new();
|
||||
|
||||
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
public MtlsOptions Mtls { get; set; } = new();
|
||||
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
public SignerIdentityOptions SignerIdentity { get; set; } = new();
|
||||
|
||||
public SubmissionLimitOptions SubmissionLimits { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MtlsOptions
|
||||
{
|
||||
public bool RequireClientCertificate { get; set; } = true;
|
||||
|
||||
public string? CaBundle { get; set; }
|
||||
|
||||
public IList<string> AllowedSubjects { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedThumbprints { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
public string? JwksUrl { get; set; }
|
||||
|
||||
public string? RequireSenderConstraint { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> RequiredScopes { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class SignerIdentityOptions
|
||||
{
|
||||
public IList<string> Mode { get; set; } = new List<string> { "keyless", "kms" };
|
||||
|
||||
public IList<string> FulcioRoots { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> AllowedSans { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> KmsKeys { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class SubmissionLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum allowed DSSE payload size, in bytes, after base64 decoding.
|
||||
/// </summary>
|
||||
public int MaxPayloadBytes { get; set; } = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of DSSE signatures accepted per submission.
|
||||
/// </summary>
|
||||
public int MaxSignatures { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of certificates allowed in the leaf-to-root chain.
|
||||
/// </summary>
|
||||
public int MaxCertificateChainEntries { get; set; } = 6;
|
||||
}
|
||||
|
||||
public sealed class RekorOptions
|
||||
{
|
||||
public RekorBackendOptions Primary { get; set; } = new();
|
||||
|
||||
public RekorMirrorOptions Mirror { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RekorBackendOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public int ProofTimeoutMs { get; set; } = 15_000;
|
||||
|
||||
public int PollIntervalMs { get; set; } = 250;
|
||||
|
||||
public int MaxAttempts { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class RekorMirrorOptions : RekorBackendOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MongoOptions
|
||||
{
|
||||
public string? Uri { get; set; }
|
||||
|
||||
public string Database { get; set; } = "attestor";
|
||||
|
||||
public string EntriesCollection { get; set; } = "entries";
|
||||
|
||||
public string DedupeCollection { get; set; } = "dedupe";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
|
||||
public string BulkJobsCollection { get; set; } = "bulk_jobs";
|
||||
}
|
||||
|
||||
public sealed class RedisOptions
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string? DedupePrefix { get; set; } = "attestor:dedupe:";
|
||||
}
|
||||
|
||||
public sealed class S3Options
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
public string? Prefix { get; set; }
|
||||
|
||||
public string? ObjectLockMode { get; set; }
|
||||
|
||||
public bool UseTls { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class QuotaOptions
|
||||
{
|
||||
public PerCallerQuotaOptions PerCaller { get; set; } = new();
|
||||
|
||||
public BulkVerificationQuotaOptions Bulk { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class PerCallerQuotaOptions
|
||||
{
|
||||
public int Qps { get; set; } = 50;
|
||||
|
||||
public int Burst { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class BulkVerificationQuotaOptions
|
||||
{
|
||||
public int RequestsPerMinute { get; set; } = 6;
|
||||
|
||||
public int MaxItemsPerJob { get; set; } = 100;
|
||||
|
||||
public int MaxQueuedJobs { get; set; } = 20;
|
||||
|
||||
public int MaxConcurrentJobs { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class CacheOptions
|
||||
{
|
||||
public VerificationCacheOptions Verification { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class VerificationCacheOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public int TtlSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool EnableLogging { get; set; } = true;
|
||||
|
||||
public bool EnableTracing { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class BulkVerificationOptions
|
||||
{
|
||||
public int WorkerPollSeconds { get; set; } = 1;
|
||||
|
||||
public int ItemDelayMilliseconds { get; set; } = 10;
|
||||
|
||||
public int MaxAttemptsPerItem { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class VerificationOptions
|
||||
{
|
||||
public string PolicyId { get; set; } = "default";
|
||||
|
||||
public string PolicyVersion { get; set; } = "1.0.0";
|
||||
|
||||
public int MinimumSignatures { get; set; } = 1;
|
||||
|
||||
public int? FreshnessMaxAgeMinutes { get; set; }
|
||||
|
||||
public int? FreshnessWarnAgeMinutes { get; set; }
|
||||
|
||||
public bool RequireTransparencyInclusion { get; set; } = true;
|
||||
|
||||
public bool RequireCheckpoint { get; set; } = true;
|
||||
|
||||
public bool RequireBundleForSignatureValidation { get; set; } = false;
|
||||
|
||||
public bool RequireWitnessEndorsement { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class TransparencyWitnessOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? BaseUrl { get; set; }
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public int RequestTimeoutMs { get; set; } = 15_000;
|
||||
|
||||
public int CacheTtlSeconds { get; set; } = 900;
|
||||
|
||||
public string? AggregatorId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SigningOptions
|
||||
{
|
||||
public IList<string> PreferredProviders { get; set; } = new List<string>();
|
||||
|
||||
public IList<SigningKeyOptions> Keys { get; set; } = new List<SigningKeyOptions>();
|
||||
|
||||
public SigningKmsOptions? Kms { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SigningKmsOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public string Algorithm { get; set; } = "ES256K";
|
||||
|
||||
public int? KeyDerivationIterations { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SigningKeyOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string? ProviderKeyId { get; set; }
|
||||
|
||||
public string? Provider { get; set; }
|
||||
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
public string? MaterialFormat { get; set; }
|
||||
|
||||
public string? Material { get; set; }
|
||||
|
||||
public string? MaterialPath { get; set; }
|
||||
|
||||
public string? MaterialPassphrase { get; set; }
|
||||
|
||||
public string? KmsKey { get; set; }
|
||||
|
||||
public string? KmsVersionId { get; set; }
|
||||
|
||||
public IList<string> CertificateChain { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Input contract for attestation signing requests.
|
||||
/// </summary>
|
||||
public sealed class AttestationSignRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifier of the signing key to use.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type (MIME).
|
||||
/// </summary>
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base64 encoded payload.
|
||||
/// </summary>
|
||||
public string PayloadBase64 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional signing mode override (e.g. keyless, kms).
|
||||
/// </summary>
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional certificate chain for keyless signatures.
|
||||
/// </summary>
|
||||
public IList<string> CertificateChain { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Artifact metadata that will be embedded in the submission meta.
|
||||
/// </summary>
|
||||
public AttestorSubmissionRequest.ArtifactInfo Artifact { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Preferred transparency log backend ("primary", "mirror", "both").
|
||||
/// </summary>
|
||||
public string LogPreference { get; set; } = "primary";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the resulting bundle should be archived.
|
||||
/// </summary>
|
||||
public bool Archive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the signed DSSE bundle ready for Rekor submission.
|
||||
/// </summary>
|
||||
public sealed class AttestationSignResult
|
||||
{
|
||||
public AttestorSubmissionRequest.SubmissionBundle Bundle { get; init; } = new();
|
||||
|
||||
public AttestorSubmissionRequest.SubmissionMeta Meta { get; init; } = new();
|
||||
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string Provider { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset SignedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
|
||||
public sealed class AttestorSigningException : Exception
|
||||
{
|
||||
public AttestorSigningException(string code, string message)
|
||||
: base(message)
|
||||
{
|
||||
Code = string.IsNullOrWhiteSpace(code) ? "signing_error" : code;
|
||||
}
|
||||
|
||||
public AttestorSigningException(string code, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Code = string.IsNullOrWhiteSpace(code) ? "signing_error" : code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Computes DSSE pre-authentication encoding (PAE) for payload signing.
|
||||
/// </summary>
|
||||
public static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("DSSEv1");
|
||||
|
||||
public static byte[] Compute(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var header = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[Prefix.Length + sizeof(long) + header.Length + sizeof(long) + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Prefix.CopyTo(buffer, offset);
|
||||
offset += Prefix.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, sizeof(long)), (ulong)header.Length);
|
||||
offset += sizeof(long);
|
||||
|
||||
header.CopyTo(buffer, offset);
|
||||
offset += header.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, sizeof(long)), (ulong)payload.Length);
|
||||
offset += sizeof(long);
|
||||
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
|
||||
public interface IAttestationSigningService
|
||||
{
|
||||
Task<AttestationSignResult> SignAsync(
|
||||
AttestationSignRequest request,
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</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="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,105 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical representation of a Rekor entry persisted in Mongo.
|
||||
/// </summary>
|
||||
public sealed class AttestorEntry
|
||||
{
|
||||
public string RekorUuid { get; init; } = string.Empty;
|
||||
|
||||
public ArtifactDescriptor Artifact { get; init; } = new();
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public ProofDescriptor? Proof { get; init; }
|
||||
|
||||
public LogDescriptor Log { get; init; } = new();
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public string Status { get; init; } = "pending";
|
||||
|
||||
public SignerIdentityDescriptor SignerIdentity { get; init; } = new();
|
||||
|
||||
public LogReplicaDescriptor? Mirror { get; init; }
|
||||
|
||||
public sealed class ArtifactDescriptor
|
||||
{
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
public string? SubjectUri { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ProofDescriptor
|
||||
{
|
||||
public CheckpointDescriptor? Checkpoint { get; init; }
|
||||
|
||||
public InclusionDescriptor? Inclusion { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CheckpointDescriptor
|
||||
{
|
||||
public string? Origin { get; init; }
|
||||
|
||||
public long Size { get; init; }
|
||||
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed class InclusionDescriptor
|
||||
{
|
||||
public string? LeafHash { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class LogDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = "primary";
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? LogId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SignerIdentityDescriptor
|
||||
{
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LogReplicaDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = string.Empty;
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public string Status { get; init; } = "pending";
|
||||
|
||||
public ProofDescriptor? Proof { get; init; }
|
||||
|
||||
public string? LogId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical representation of a Rekor entry persisted in Mongo.
|
||||
/// </summary>
|
||||
public sealed class AttestorEntry
|
||||
{
|
||||
public string RekorUuid { get; init; } = string.Empty;
|
||||
|
||||
public ArtifactDescriptor Artifact { get; init; } = new();
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public ProofDescriptor? Proof { get; init; }
|
||||
|
||||
public WitnessDescriptor? Witness { get; init; }
|
||||
|
||||
public LogDescriptor Log { get; init; } = new();
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public string Status { get; init; } = "pending";
|
||||
|
||||
public SignerIdentityDescriptor SignerIdentity { get; init; } = new();
|
||||
|
||||
public LogReplicaDescriptor? Mirror { get; init; }
|
||||
|
||||
public sealed class ArtifactDescriptor
|
||||
{
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
public string? SubjectUri { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ProofDescriptor
|
||||
{
|
||||
public CheckpointDescriptor? Checkpoint { get; init; }
|
||||
|
||||
public InclusionDescriptor? Inclusion { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WitnessDescriptor
|
||||
{
|
||||
public string Aggregator { get; init; } = string.Empty;
|
||||
|
||||
public string Status { get; init; } = "unknown";
|
||||
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
public DateTimeOffset RetrievedAt { get; init; }
|
||||
|
||||
public string? Statement { get; init; }
|
||||
|
||||
public string? Signature { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CheckpointDescriptor
|
||||
{
|
||||
public string? Origin { get; init; }
|
||||
|
||||
public long Size { get; init; }
|
||||
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed class InclusionDescriptor
|
||||
{
|
||||
public string? LeafHash { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class LogDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = "primary";
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? LogId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SignerIdentityDescriptor
|
||||
{
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LogReplicaDescriptor
|
||||
{
|
||||
public string Backend { get; init; } = string.Empty;
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public string Status { get; init; } = "pending";
|
||||
|
||||
public ProofDescriptor? Proof { get; init; }
|
||||
|
||||
public string? LogId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
|
||||
public WitnessDescriptor? Witness { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes pagination state for attestor entry listings.
|
||||
/// </summary>
|
||||
public static class AttestorEntryContinuationToken
|
||||
{
|
||||
private const char Separator = '|';
|
||||
|
||||
public readonly record struct Cursor(DateTimeOffset CreatedAt, string RekorUuid);
|
||||
|
||||
public static string Encode(DateTimeOffset createdAt, string rekorUuid)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rekorUuid);
|
||||
|
||||
var ticksText = createdAt.UtcTicks.ToString(CultureInfo.InvariantCulture);
|
||||
var payload = string.Concat(ticksText, Separator, rekorUuid);
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
|
||||
public static Cursor Parse(string token)
|
||||
{
|
||||
if (!TryParse(token, out var cursor))
|
||||
{
|
||||
throw new FormatException("Invalid attestor continuation token.");
|
||||
}
|
||||
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public static bool TryParse(string? token, out Cursor cursor)
|
||||
{
|
||||
cursor = default;
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(token);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var decoded = Encoding.UTF8.GetString(data);
|
||||
var separatorIndex = decoded.IndexOf(Separator, StringComparison.Ordinal);
|
||||
if (separatorIndex <= 0 || separatorIndex == decoded.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ticksSpan = decoded.AsSpan(0, separatorIndex);
|
||||
if (!long.TryParse(ticksSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var uuid = decoded[(separatorIndex + 1)..];
|
||||
if (string.IsNullOrEmpty(uuid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var createdAt = new DateTimeOffset(ticks, TimeSpan.Zero);
|
||||
cursor = new Cursor(createdAt, uuid);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing attestor entries.
|
||||
/// </summary>
|
||||
public sealed class AttestorEntryQuery
|
||||
{
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? Type { get; init; }
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? Scope { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paginated page of attestor entries.
|
||||
/// </summary>
|
||||
public sealed class AttestorEntryQueryResult
|
||||
{
|
||||
public IReadOnlyList<AttestorEntry> Items { get; init; } = Array.Empty<AttestorEntry>();
|
||||
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
public interface IAttestorArchiveStore
|
||||
{
|
||||
Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default);
|
||||
}
|
||||
public interface IAttestorArchiveStore
|
||||
{
|
||||
Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestorArchiveBundle?> GetBundleAsync(string bundleSha256, string rekorUuid, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ public interface IAttestorEntryRepository
|
||||
{
|
||||
Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestorEntryQueryResult> QueryAsync(AttestorEntryQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned to callers after processing a submission.
|
||||
/// </summary>
|
||||
public sealed class AttestorSubmissionResult
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProof? Proof { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("mirror")]
|
||||
public MirrorLog? Mirror { get; set; }
|
||||
|
||||
public sealed class RekorProof
|
||||
{
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public Checkpoint? Checkpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("inclusion")]
|
||||
public InclusionProof? Inclusion { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Checkpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string? Origin { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; set; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InclusionProof
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string? LeafHash { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class MirrorLog
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProof? Proof { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned to callers after processing a submission.
|
||||
/// </summary>
|
||||
public sealed class AttestorSubmissionResult
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProof? Proof { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("mirror")]
|
||||
public MirrorLog? Mirror { get; set; }
|
||||
|
||||
[JsonPropertyName("witness")]
|
||||
public WitnessStatement? Witness { get; set; }
|
||||
|
||||
public sealed class RekorProof
|
||||
{
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public Checkpoint? Checkpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("inclusion")]
|
||||
public InclusionProof? Inclusion { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Checkpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string? Origin { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; set; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InclusionProof
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string? LeafHash { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class MirrorLog
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProof? Proof { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("witness")]
|
||||
public WitnessStatement? Witness { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WitnessStatement
|
||||
{
|
||||
[JsonPropertyName("aggregator")]
|
||||
public string? Aggregator { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "unknown";
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; set; }
|
||||
|
||||
[JsonPropertyName("retrievedAt")]
|
||||
public string? RetrievedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("statement")]
|
||||
public string? Statement { get; set; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,19 +12,24 @@ public sealed class AttestorSubmissionValidator
|
||||
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
|
||||
|
||||
private readonly IDsseCanonicalizer _canonicalizer;
|
||||
private readonly HashSet<string> _allowedModes;
|
||||
|
||||
public AttestorSubmissionValidator(IDsseCanonicalizer canonicalizer, IEnumerable<string>? allowedModes = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_allowedModes = allowedModes is null
|
||||
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(allowedModes, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
private readonly HashSet<string> _allowedModes;
|
||||
private readonly AttestorSubmissionConstraints _constraints;
|
||||
|
||||
public AttestorSubmissionValidator(
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IEnumerable<string>? allowedModes = null,
|
||||
AttestorSubmissionConstraints? constraints = null)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_allowedModes = allowedModes is null
|
||||
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(allowedModes, StringComparer.OrdinalIgnoreCase);
|
||||
_constraints = constraints ?? AttestorSubmissionConstraints.Default;
|
||||
}
|
||||
|
||||
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (request.Bundle is null)
|
||||
{
|
||||
@@ -42,20 +47,25 @@ public sealed class AttestorSubmissionValidator
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadBase64))
|
||||
{
|
||||
throw new AttestorValidationException("payload_missing", "DSSE payload must be provided.");
|
||||
}
|
||||
|
||||
if (request.Bundle.Dsse.Signatures.Count == 0)
|
||||
{
|
||||
throw new AttestorValidationException("signature_missing", "At least one DSSE signature is required.");
|
||||
}
|
||||
|
||||
if (_allowedModes.Count > 0 && !string.IsNullOrWhiteSpace(request.Bundle.Mode) && !_allowedModes.Contains(request.Bundle.Mode))
|
||||
{
|
||||
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
|
||||
}
|
||||
|
||||
{
|
||||
throw new AttestorValidationException("payload_missing", "DSSE payload must be provided.");
|
||||
}
|
||||
|
||||
if (request.Bundle.Dsse.Signatures.Count == 0)
|
||||
{
|
||||
throw new AttestorValidationException("signature_missing", "At least one DSSE signature is required.");
|
||||
}
|
||||
|
||||
if (request.Bundle.Dsse.Signatures.Count > _constraints.MaxSignatures)
|
||||
{
|
||||
throw new AttestorValidationException("signature_limit_exceeded", $"A maximum of {_constraints.MaxSignatures} DSSE signatures is permitted per submission.");
|
||||
}
|
||||
|
||||
if (_allowedModes.Count > 0 && !string.IsNullOrWhiteSpace(request.Bundle.Mode) && !_allowedModes.Contains(request.Bundle.Mode))
|
||||
{
|
||||
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
|
||||
}
|
||||
|
||||
if (request.Meta is null)
|
||||
{
|
||||
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
|
||||
@@ -86,21 +96,31 @@ public sealed class AttestorSubmissionValidator
|
||||
throw new AttestorValidationException("bundle_sha_invalid", "bundleSha256 must be a 64-character hex string.");
|
||||
}
|
||||
|
||||
if (Array.IndexOf(AllowedKinds, request.Meta.Artifact.Kind) < 0)
|
||||
{
|
||||
throw new AttestorValidationException("artifact_kind_invalid", $"Artifact kind '{request.Meta.Artifact.Kind}' is not supported.");
|
||||
}
|
||||
|
||||
if (!Base64UrlDecode(request.Bundle.Dsse.PayloadBase64, out _))
|
||||
{
|
||||
throw new AttestorValidationException("payload_invalid_base64", "DSSE payload must be valid base64.");
|
||||
}
|
||||
|
||||
var canonical = await _canonicalizer.CanonicalizeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(canonical, hash, out _))
|
||||
{
|
||||
throw new AttestorValidationException("bundle_sha_failure", "Failed to compute canonical bundle hash.");
|
||||
if (Array.IndexOf(AllowedKinds, request.Meta.Artifact.Kind) < 0)
|
||||
{
|
||||
throw new AttestorValidationException("artifact_kind_invalid", $"Artifact kind '{request.Meta.Artifact.Kind}' is not supported.");
|
||||
}
|
||||
|
||||
if (request.Bundle.CertificateChain.Count > _constraints.MaxCertificateChainEntries)
|
||||
{
|
||||
throw new AttestorValidationException("certificate_chain_too_long", $"Certificate chain length exceeds {_constraints.MaxCertificateChainEntries} entries.");
|
||||
}
|
||||
|
||||
if (!Base64UrlDecode(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
throw new AttestorValidationException("payload_invalid_base64", "DSSE payload must be valid base64.");
|
||||
}
|
||||
|
||||
if (payloadBytes.Length > _constraints.MaxPayloadBytes)
|
||||
{
|
||||
throw new AttestorValidationException("payload_too_large", $"DSSE payload exceeds {_constraints.MaxPayloadBytes} bytes limit.");
|
||||
}
|
||||
|
||||
var canonical = await _canonicalizer.CanonicalizeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(canonical, hash, out _))
|
||||
{
|
||||
throw new AttestorValidationException("bundle_sha_failure", "Failed to compute canonical bundle hash.");
|
||||
}
|
||||
|
||||
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
@@ -172,5 +192,41 @@ public sealed class AttestorSubmissionValidator
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AttestorSubmissionConstraints
|
||||
{
|
||||
public static AttestorSubmissionConstraints Default { get; } = new();
|
||||
|
||||
public AttestorSubmissionConstraints(
|
||||
int maxPayloadBytes = 2 * 1024 * 1024,
|
||||
int maxSignatures = 6,
|
||||
int maxCertificateChainEntries = 6)
|
||||
{
|
||||
if (maxPayloadBytes <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxPayloadBytes), "Max payload bytes must be positive.");
|
||||
}
|
||||
|
||||
if (maxSignatures <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSignatures), "Max signatures must be positive.");
|
||||
}
|
||||
|
||||
if (maxCertificateChainEntries <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxCertificateChainEntries), "Max certificate chain entries must be positive.");
|
||||
}
|
||||
|
||||
MaxPayloadBytes = maxPayloadBytes;
|
||||
MaxSignatures = maxSignatures;
|
||||
MaxCertificateChainEntries = maxCertificateChainEntries;
|
||||
}
|
||||
|
||||
public int MaxPayloadBytes { get; }
|
||||
|
||||
public int MaxSignatures { get; }
|
||||
|
||||
public int MaxCertificateChainEntries { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Transparency;
|
||||
|
||||
public interface ITransparencyWitnessClient
|
||||
{
|
||||
Task<TransparencyWitnessObservation?> GetObservationAsync(TransparencyWitnessRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Transparency;
|
||||
|
||||
public sealed class TransparencyWitnessObservation
|
||||
{
|
||||
public string Aggregator { get; init; } = string.Empty;
|
||||
|
||||
public string Status { get; init; } = "unknown";
|
||||
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
public DateTimeOffset RetrievedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string? Statement { get; init; }
|
||||
|
||||
public string? Signature { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Transparency;
|
||||
|
||||
public sealed record TransparencyWitnessRequest(
|
||||
string Uuid,
|
||||
string Backend,
|
||||
Uri BackendUrl,
|
||||
string? CheckpointRootHash);
|
||||
@@ -5,11 +5,23 @@ namespace StellaOps.Attestor.Core.Verification;
|
||||
/// </summary>
|
||||
public sealed class AttestorVerificationRequest
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
public Submission.AttestorSubmissionRequest.SubmissionBundle? Bundle { get; set; }
|
||||
|
||||
public string? ArtifactSha256 { get; set; }
|
||||
|
||||
public bool RefreshProof { get; set; }
|
||||
}
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
public Submission.AttestorSubmissionRequest.SubmissionBundle? Bundle { get; set; }
|
||||
|
||||
public string? ArtifactSha256 { get; set; }
|
||||
|
||||
public string? Subject { get; set; }
|
||||
|
||||
public string? EnvelopeId { get; set; }
|
||||
|
||||
public string? PolicyVersion { get; set; }
|
||||
|
||||
public bool RefreshProof { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, verification does not attempt to contact external transparency logs and
|
||||
/// surfaces issues for missing proofs instead.
|
||||
/// </summary>
|
||||
public bool Offline { get; set; }
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ public sealed class AttestorVerificationResult
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public string? LogUrl { get; init; }
|
||||
|
||||
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string Status { get; init; } = "unknown";
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
public string? LogUrl { get; init; }
|
||||
|
||||
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string Status { get; init; } = "unknown";
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
|
||||
public VerificationReport? Report { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
public interface IAttestorVerificationCache
|
||||
{
|
||||
Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
public sealed class PolicyEvaluationResult
|
||||
{
|
||||
public VerificationSectionStatus Status { get; init; } = VerificationSectionStatus.Skipped;
|
||||
|
||||
public string PolicyId { get; init; } = "default";
|
||||
|
||||
public string PolicyVersion { get; init; } = "0.0.0";
|
||||
|
||||
public string Verdict { get; init; } = "unknown";
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public sealed class IssuerEvaluationResult
|
||||
{
|
||||
public VerificationSectionStatus Status { get; init; } = VerificationSectionStatus.Skipped;
|
||||
|
||||
public string Mode { get; init; } = "unknown";
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? SubjectAlternativeName { get; init; }
|
||||
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class FreshnessEvaluationResult
|
||||
{
|
||||
public VerificationSectionStatus Status { get; init; } = VerificationSectionStatus.Skipped;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
public TimeSpan Age { get; init; }
|
||||
|
||||
public TimeSpan? MaxAge { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class SignatureEvaluationResult
|
||||
{
|
||||
public VerificationSectionStatus Status { get; init; } = VerificationSectionStatus.Skipped;
|
||||
|
||||
public bool BundleProvided { get; init; }
|
||||
|
||||
public int TotalSignatures { get; init; }
|
||||
|
||||
public int VerifiedSignatures { get; init; }
|
||||
|
||||
public int RequiredSignatures { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class TransparencyEvaluationResult
|
||||
{
|
||||
public VerificationSectionStatus Status { get; init; } = VerificationSectionStatus.Skipped;
|
||||
|
||||
public bool ProofPresent { get; init; }
|
||||
|
||||
public bool CheckpointPresent { get; init; }
|
||||
|
||||
public bool InclusionPathPresent { get; init; }
|
||||
|
||||
public bool WitnessPresent { get; init; }
|
||||
|
||||
public bool WitnessMatchesRoot { get; init; }
|
||||
|
||||
public string? WitnessAggregator { get; init; }
|
||||
|
||||
public string WitnessStatus { get; init; } = "missing";
|
||||
|
||||
public IReadOnlyList<string> Issues { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class VerificationReport
|
||||
{
|
||||
public VerificationSectionStatus OverallStatus { get; }
|
||||
|
||||
public PolicyEvaluationResult Policy { get; }
|
||||
|
||||
public IssuerEvaluationResult Issuer { get; }
|
||||
|
||||
public FreshnessEvaluationResult Freshness { get; }
|
||||
|
||||
public SignatureEvaluationResult Signatures { get; }
|
||||
|
||||
public TransparencyEvaluationResult Transparency { get; }
|
||||
|
||||
public IReadOnlyList<string> Issues { get; }
|
||||
|
||||
public VerificationReport(
|
||||
PolicyEvaluationResult policy,
|
||||
IssuerEvaluationResult issuer,
|
||||
FreshnessEvaluationResult freshness,
|
||||
SignatureEvaluationResult signatures,
|
||||
TransparencyEvaluationResult transparency)
|
||||
{
|
||||
Policy = policy ?? throw new ArgumentNullException(nameof(policy));
|
||||
Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer));
|
||||
Freshness = freshness ?? throw new ArgumentNullException(nameof(freshness));
|
||||
Signatures = signatures ?? throw new ArgumentNullException(nameof(signatures));
|
||||
Transparency = transparency ?? throw new ArgumentNullException(nameof(transparency));
|
||||
|
||||
OverallStatus = DetermineOverallStatus(policy, issuer, freshness, signatures, transparency);
|
||||
Issues = AggregateIssues(policy, issuer, freshness, signatures, transparency);
|
||||
}
|
||||
|
||||
public bool Succeeded => OverallStatus == VerificationSectionStatus.Pass || OverallStatus == VerificationSectionStatus.Warn;
|
||||
|
||||
private static VerificationSectionStatus DetermineOverallStatus(params object[] sections)
|
||||
{
|
||||
var statuses = sections
|
||||
.OfType<object>()
|
||||
.Select(section => section switch
|
||||
{
|
||||
PolicyEvaluationResult p => p.Status,
|
||||
IssuerEvaluationResult i => i.Status,
|
||||
FreshnessEvaluationResult f => f.Status,
|
||||
SignatureEvaluationResult s => s.Status,
|
||||
TransparencyEvaluationResult t => t.Status,
|
||||
_ => VerificationSectionStatus.Skipped
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (statuses.Any(status => status == VerificationSectionStatus.Fail))
|
||||
{
|
||||
return VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
if (statuses.Any(status => status == VerificationSectionStatus.Warn))
|
||||
{
|
||||
return VerificationSectionStatus.Warn;
|
||||
}
|
||||
|
||||
if (statuses.All(status => status == VerificationSectionStatus.Skipped))
|
||||
{
|
||||
return VerificationSectionStatus.Skipped;
|
||||
}
|
||||
|
||||
return VerificationSectionStatus.Pass;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> AggregateIssues(params object[] sections)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var issues = section switch
|
||||
{
|
||||
PolicyEvaluationResult p => p.Issues,
|
||||
IssuerEvaluationResult i => i.Issues,
|
||||
FreshnessEvaluationResult f => f.Issues,
|
||||
SignatureEvaluationResult s => s.Issues,
|
||||
TransparencyEvaluationResult t => t.Issues,
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(issue))
|
||||
{
|
||||
set.Add(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return set.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the evaluation status of an individual verification section.
|
||||
/// </summary>
|
||||
public enum VerificationSectionStatus
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Skipped
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using MongoDB.Driver;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Offline;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.WebService;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestationBundleEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportEndpoint_RequiresAuthentication()
|
||||
{
|
||||
using var factory = new AttestorWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/api/v1/attestations:export", new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAndImportEndpoints_RoundTripBundles()
|
||||
{
|
||||
using var factory = new AttestorWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
AttachAuth(client);
|
||||
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\"}");
|
||||
var bundleHashBytes = System.Security.Cryptography.SHA256.HashData(canonicalBytes);
|
||||
var bundleSha = Convert.ToHexString(bundleHashBytes).ToLowerInvariant();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAttestorEntryRepository>();
|
||||
var archiveStore = scope.ServiceProvider.GetRequiredService<IAttestorArchiveStore>();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "uuid-export-01",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "feedface",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/uuid-export-01"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Issuer = "tenant-a"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
await archiveStore.ArchiveBundleAsync(new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBytes,
|
||||
ProofJson = Array.Empty<byte>(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = entry.Status
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalBytes2 = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\",\"sequence\":2}");
|
||||
var bundleSha2 = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBytes2)).ToLowerInvariant();
|
||||
var secondEntry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "uuid-export-02",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "deadcafe",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha2,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/uuid-export-02"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Issuer = "tenant-a"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(secondEntry);
|
||||
await archiveStore.ArchiveBundleAsync(new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = secondEntry.RekorUuid,
|
||||
ArtifactSha256 = secondEntry.Artifact.Sha256,
|
||||
BundleSha256 = secondEntry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBytes2,
|
||||
ProofJson = Array.Empty<byte>(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = secondEntry.Status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var exportResponse = await client.PostAsJsonAsync("/api/v1/attestations:export", new
|
||||
{
|
||||
scope = "tenant-a",
|
||||
limit = 1
|
||||
});
|
||||
|
||||
exportResponse.EnsureSuccessStatusCode();
|
||||
var exportPayload = await exportResponse.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(exportPayload), "Export response payload was empty.");
|
||||
var package = JsonSerializer.Deserialize<AttestorBundlePackage>(
|
||||
exportPayload,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(package);
|
||||
Assert.Single(package!.Items);
|
||||
Assert.NotNull(package.ContinuationToken);
|
||||
|
||||
var importResponse = await client.PostAsJsonAsync("/api/v1/attestations:import", package);
|
||||
importResponse.EnsureSuccessStatusCode();
|
||||
var importPayload = await importResponse.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(importPayload), "Import response payload was empty.");
|
||||
var importResult = JsonSerializer.Deserialize<AttestorBundleImportResult>(
|
||||
importPayload,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(importResult);
|
||||
Assert.Equal(0, importResult!.Imported);
|
||||
Assert.Equal(1, importResult.Updated);
|
||||
Assert.Empty(importResult.Issues);
|
||||
}
|
||||
|
||||
private static void AttachAuth(HttpClient client)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AttestorWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["attestor:s3:enabled"] = "true",
|
||||
["attestor:s3:bucket"] = "attestor-test",
|
||||
["attestor:s3:endpoint"] = "http://localhost",
|
||||
["attestor:s3:useTls"] = "false",
|
||||
["attestor:redis:url"] = string.Empty,
|
||||
["attestor:mongo:uri"] = "mongodb://localhost:27017/attestor-tests",
|
||||
["attestor:mongo:database"] = "attestor-tests"
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(settings!);
|
||||
});
|
||||
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.RemoveAll<IConnectionMultiplexer>();
|
||||
services.RemoveAll<IAttestorEntryRepository>();
|
||||
services.RemoveAll<IAttestorArchiveStore>();
|
||||
services.RemoveAll<IAttestorAuditSink>();
|
||||
services.RemoveAll<IAttestorDedupeStore>();
|
||||
services.RemoveAll<IAttestorBundleService>();
|
||||
services.RemoveAll<ITransparencyWitnessClient>();
|
||||
services.RemoveAll<IAttestationSigningService>();
|
||||
services.RemoveAll<IBulkVerificationJobStore>();
|
||||
services.AddSingleton<IAttestorEntryRepository, InMemoryAttestorEntryRepository>();
|
||||
services.AddSingleton<IAttestorArchiveStore, InMemoryAttestorArchiveStore>();
|
||||
services.AddSingleton<IAttestorAuditSink, InMemoryAttestorAuditSink>();
|
||||
services.AddSingleton<IAttestorDedupeStore, InMemoryAttestorDedupeStore>();
|
||||
services.AddSingleton<IAttestorBundleService, AttestorBundleService>();
|
||||
services.AddSingleton<ITransparencyWitnessClient, TestTransparencyWitnessClient>();
|
||||
services.AddSingleton<IAttestationSigningService, TestAttestationSigningService>();
|
||||
services.AddSingleton<IBulkVerificationJobStore, TestBulkVerificationJobStore>();
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
|
||||
authenticationScheme: TestAuthHandler.SchemeName,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
#pragma warning disable CS0618
|
||||
services.TryAddSingleton<ISystemClock, SystemClock>();
|
||||
#pragma warning restore CS0618
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var header) || header.Count == 0)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "test-user"),
|
||||
new Claim("scope", "attestor.read attestor.write attestor.verify")
|
||||
};
|
||||
|
||||
var schemeName = Scheme?.Name ?? SchemeName;
|
||||
var identity = new ClaimsIdentity(claims, schemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, schemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestationQueryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersAndPagination_Work()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var origin = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
var scope = index % 2 == 0 ? "tenant-a" : "tenant-b";
|
||||
var type = index % 2 == 0 ? "sbom" : "report";
|
||||
var issuer = $"issuer-{index % 2}";
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = $"uuid-{index:D2}",
|
||||
BundleSha256 = $"bundle-{index:D2}",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = $"sha-{index:D2}",
|
||||
Kind = type,
|
||||
ImageDigest = $"sha256:{index:D2}",
|
||||
SubjectUri = $"pkg:example/app@{index}"
|
||||
},
|
||||
CreatedAt = origin.AddMinutes(index),
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = $"https://rekor.example/entries/{index:D2}"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = scope,
|
||||
SubjectAlternativeName = issuer,
|
||||
KeyId = $"kid-{index}"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
}
|
||||
|
||||
var query = new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 3
|
||||
};
|
||||
|
||||
var firstPage = await repository.QueryAsync(query);
|
||||
Assert.Equal(3, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.ContinuationToken);
|
||||
Assert.All(firstPage.Items, item =>
|
||||
{
|
||||
Assert.Equal("tenant-a", item.SignerIdentity.Issuer);
|
||||
Assert.Equal("sbom", item.Artifact.Kind);
|
||||
});
|
||||
var firstPageIds = firstPage.Items.Select(item => item.RekorUuid).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var secondPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = query.Scope,
|
||||
Type = query.Type,
|
||||
PageSize = query.PageSize,
|
||||
ContinuationToken = firstPage.ContinuationToken
|
||||
});
|
||||
|
||||
Assert.True(secondPage.Items.Count > 0);
|
||||
Assert.All(secondPage.Items, item => Assert.DoesNotContain(item.RekorUuid, firstPageIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildQuery_ValidatesInputs()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.QueryString = new QueryString("?subject=sha-01&type=sbom&issuer=issuer-0&scope=tenant-a&pageSize=25&createdAfter=2025-01-01T00:00:00Z&createdBefore=2025-01-31T00:00:00Z");
|
||||
|
||||
var success = AttestationListContracts.TryBuildQuery(httpContext.Request, out var query, out var error);
|
||||
Assert.True(success);
|
||||
Assert.Null(error);
|
||||
Assert.Equal("sha-01", query.Subject);
|
||||
Assert.Equal("sbom", query.Type);
|
||||
Assert.Equal("issuer-0", query.Issuer);
|
||||
Assert.Equal("tenant-a", query.Scope);
|
||||
Assert.Equal(25, query.PageSize);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-01-01T00:00:00Z"), query.CreatedAfter);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-01-31T00:00:00Z"), query.CreatedBefore);
|
||||
|
||||
httpContext.Request.QueryString = new QueryString("?pageSize=-5");
|
||||
success = AttestationListContracts.TryBuildQuery(httpContext.Request, out _, out error);
|
||||
Assert.False(success);
|
||||
var problem = Assert.IsType<ProblemHttpResult>(error);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorEntryRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersAndPagination_Work()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var origin = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var index = 0; index < 6; index++)
|
||||
{
|
||||
var scope = index % 2 == 0 ? "tenant-a" : "tenant-b";
|
||||
var kind = index % 2 == 0 ? "sbom" : "report";
|
||||
var entry = CreateEntry(index, origin.AddMinutes(index), scope, kind);
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
}
|
||||
|
||||
var firstPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 2
|
||||
});
|
||||
|
||||
Assert.Equal(2, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.ContinuationToken);
|
||||
Assert.All(firstPage.Items, item =>
|
||||
{
|
||||
Assert.Equal("tenant-a", item.SignerIdentity.Issuer);
|
||||
Assert.Equal("sbom", item.Artifact.Kind);
|
||||
});
|
||||
|
||||
var seen = firstPage.Items.Select(item => item.RekorUuid).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var secondPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 2,
|
||||
ContinuationToken = firstPage.ContinuationToken
|
||||
});
|
||||
|
||||
Assert.True(secondPage.Items.Count > 0);
|
||||
Assert.All(secondPage.Items, item => Assert.DoesNotContain(item.RekorUuid, seen));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_EnforcesUniqueBundleSha()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var entryA = CreateEntry(100, now, "tenant-a", "sbom");
|
||||
var entryB = CreateEntry(200, now.AddMinutes(1), "tenant-b", "report", entryA.BundleSha256);
|
||||
|
||||
await repository.SaveAsync(entryA);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.SaveAsync(entryB));
|
||||
}
|
||||
|
||||
private static AttestorEntry CreateEntry(int index, DateTimeOffset createdAt, string scope, string kind, string? bundleShaOverride = null)
|
||||
{
|
||||
var uuid = $"uuid-{index:D4}";
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = uuid,
|
||||
BundleSha256 = bundleShaOverride ?? MakeHex(10_000 + index),
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = MakeHex(20_000 + index),
|
||||
Kind = kind,
|
||||
ImageDigest = $"sha256:{index:D4}",
|
||||
SubjectUri = $"pkg:example/app@{index}"
|
||||
},
|
||||
Index = index,
|
||||
Proof = null,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = $"https://rekor.example/entries/{index:D4}",
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = createdAt,
|
||||
Status = "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = scope,
|
||||
SubjectAlternativeName = $"issuer-{index % 3}",
|
||||
KeyId = $"kid-{index:D4}"
|
||||
},
|
||||
Mirror = new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = "mirror",
|
||||
Url = $"https://rekor-mirror.example/{index:D4}",
|
||||
Uuid = $"mirror-{uuid}",
|
||||
Index = index,
|
||||
Status = "pending",
|
||||
Proof = null,
|
||||
LogId = null,
|
||||
Error = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string MakeHex(int seed)
|
||||
=> Convert.ToHexString(BitConverter.GetBytes(seed)).ToLowerInvariant().PadLeft(64, '0');
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Signing;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Tests;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorSigningServiceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _temporaryPaths = new();
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_Ed25519Key_ReturnsValidSignature()
|
||||
{
|
||||
var privateKey = new byte[32];
|
||||
for (var i = 0; i < privateKey.Length; i++)
|
||||
{
|
||||
privateKey[i] = (byte)i;
|
||||
}
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = "ed25519-1",
|
||||
Algorithm = StellaOps.Cryptography.SignatureAlgorithms.Ed25519,
|
||||
Mode = "keyful",
|
||||
Material = Convert.ToBase64String(privateKey),
|
||||
MaterialFormat = "base64"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
new DefaultDsseCanonicalizer(),
|
||||
auditSink,
|
||||
metrics,
|
||||
NullLogger<AttestorSigningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestationSignRequest
|
||||
{
|
||||
KeyId = "ed25519-1",
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(payloadBytes),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:subject",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "client",
|
||||
CallerTenant = "tenant",
|
||||
MtlsThumbprint = "thumbprint"
|
||||
};
|
||||
|
||||
var result = await service.SignAsync(request, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ed25519-1", result.KeyId);
|
||||
Assert.Equal("keyful", result.Mode);
|
||||
Assert.Equal("bouncycastle.ed25519", result.Provider);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
|
||||
Assert.Single(result.Bundle.Dsse.Signatures);
|
||||
|
||||
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
|
||||
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
|
||||
var verifier = new Org.BouncyCastle.Crypto.Signers.Ed25519Signer();
|
||||
var privateParams = new Org.BouncyCastle.Crypto.Parameters.Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
verifier.Init(false, privateParams.GeneratePublicKey());
|
||||
verifier.BlockUpdate(preAuth, 0, preAuth.Length);
|
||||
Assert.True(verifier.VerifySignature(signature));
|
||||
|
||||
Assert.Single(auditSink.Records);
|
||||
Assert.Equal("sign", auditSink.Records[0].Action);
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_KmsKey_ProducesVerifiableSignature()
|
||||
{
|
||||
var kmsRoot = CreateTempDirectory();
|
||||
const string kmsPassword = "Test-Kms-Password!";
|
||||
const string kmsKeyId = "kms-key-1";
|
||||
const string kmsVersion = "v1";
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var keyParameters = ecdsa.ExportParameters(true);
|
||||
var publicParameters = new ECParameters
|
||||
{
|
||||
Curve = keyParameters.Curve,
|
||||
Q = keyParameters.Q
|
||||
};
|
||||
|
||||
using (var kmsClient = new FileKmsClient(new FileKmsOptions
|
||||
{
|
||||
RootPath = kmsRoot,
|
||||
Password = kmsPassword
|
||||
}))
|
||||
{
|
||||
var material = new KmsKeyMaterial(
|
||||
kmsKeyId,
|
||||
kmsVersion,
|
||||
KmsAlgorithms.Es256,
|
||||
"P-256",
|
||||
keyParameters.D!,
|
||||
keyParameters.Q.X!,
|
||||
keyParameters.Q.Y!,
|
||||
DateTimeOffset.UtcNow);
|
||||
await kmsClient.ImportAsync(kmsKeyId, material);
|
||||
}
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Kms = new AttestorOptions.SigningKmsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RootPath = kmsRoot,
|
||||
Password = kmsPassword
|
||||
},
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = kmsKeyId,
|
||||
Algorithm = StellaOps.Cryptography.SignatureAlgorithms.Es256,
|
||||
Mode = "kms",
|
||||
ProviderKeyId = kmsKeyId,
|
||||
KmsVersionId = kmsVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
new DefaultDsseCanonicalizer(),
|
||||
auditSink,
|
||||
metrics,
|
||||
NullLogger<AttestorSigningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("{\"value\":1}");
|
||||
var request = new AttestationSignRequest
|
||||
{
|
||||
KeyId = kmsKeyId,
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('b', 64),
|
||||
Kind = "report"
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:subject",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "tenant"
|
||||
};
|
||||
|
||||
var result = await service.SignAsync(request, context);
|
||||
Assert.Equal("kms", result.Mode);
|
||||
Assert.Equal("kms", result.Provider);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
|
||||
|
||||
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
|
||||
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
|
||||
using var verifier = ECDsa.Create(publicParameters);
|
||||
|
||||
Assert.True(verifier.VerifyData(preAuth, signature, HashAlgorithmName.SHA256));
|
||||
Assert.Single(auditSink.Records);
|
||||
Assert.Equal("sign", auditSink.Records[0].Action);
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
|
||||
private string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "attestor-signing-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
_temporaryPaths.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var path in _temporaryPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
_temporaryPaths.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_PersistsAndFetchesEntry()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var entry = CreateEntry();
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var byUuid = await repository.GetByUuidAsync(entry.RekorUuid);
|
||||
var byBundle = await repository.GetByBundleShaAsync(entry.BundleSha256);
|
||||
var byArtifact = await repository.GetByArtifactShaAsync(entry.Artifact.Sha256);
|
||||
|
||||
Assert.NotNull(byUuid);
|
||||
Assert.NotNull(byBundle);
|
||||
Assert.Equal(entry.RekorUuid, byBundle!.RekorUuid);
|
||||
Assert.Single(byArtifact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_UpsertsExistingDocument()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var entry = CreateEntry(status: "included");
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var updated = CreateEntry(
|
||||
rekorUuid: entry.RekorUuid,
|
||||
bundleSha: entry.BundleSha256,
|
||||
artifactSha: entry.Artifact.Sha256,
|
||||
status: "pending");
|
||||
|
||||
await repository.SaveAsync(updated);
|
||||
|
||||
var stored = await repository.GetByUuidAsync(entry.RekorUuid);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pending", stored!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryDedupeStore_RoundTripsAndExpires()
|
||||
{
|
||||
var store = new InMemoryAttestorDedupeStore();
|
||||
var bundleSha = Guid.NewGuid().ToString("N");
|
||||
var uuid = Guid.NewGuid().ToString("N");
|
||||
|
||||
await store.SetAsync(bundleSha, uuid, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var first = await store.TryGetExistingAsync(bundleSha);
|
||||
Assert.Equal(uuid, first);
|
||||
|
||||
// fast-forward past expiry
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(75));
|
||||
var second = await store.TryGetExistingAsync(bundleSha);
|
||||
Assert.Null(second);
|
||||
}
|
||||
|
||||
private static AttestorEntry CreateEntry(
|
||||
string? rekorUuid = null,
|
||||
string? bundleSha = null,
|
||||
string? artifactSha = null,
|
||||
string status = "included")
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = rekorUuid ?? Guid.NewGuid().ToString("N"),
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = artifactSha ?? "sha256:" + Guid.NewGuid().ToString("N"),
|
||||
Kind = "sbom",
|
||||
ImageDigest = "sha256:" + Guid.NewGuid().ToString("N"),
|
||||
SubjectUri = "oci://registry.example/app"
|
||||
},
|
||||
BundleSha256 = bundleSha ?? Guid.NewGuid().ToString("N"),
|
||||
Index = 42,
|
||||
Proof = null,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/api/v1/log",
|
||||
LogId = "log-1"
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = status,
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = "tenant-a",
|
||||
SubjectAlternativeName = "signer@example",
|
||||
KeyId = "kid"
|
||||
},
|
||||
Mirror = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
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.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
@@ -41,22 +46,26 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var verificationCache = new StubVerificationCache();
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
verificationCache,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
var context = new SubmissionContext
|
||||
@@ -72,12 +81,14 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var first = await service.SubmitAsync(request, context);
|
||||
var second = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(first.Uuid);
|
||||
Assert.Equal(first.Uuid, second.Uuid);
|
||||
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(first.Uuid, stored!.RekorUuid);
|
||||
Assert.NotNull(first.Uuid);
|
||||
Assert.Equal(first.Uuid, second.Uuid);
|
||||
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(first.Uuid, stored!.RekorUuid);
|
||||
Assert.Single(verificationCache.InvalidatedSubjects);
|
||||
Assert.Equal(request.Meta.Artifact.Sha256, verificationCache.InvalidatedSubjects[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -115,22 +126,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
@@ -178,22 +192,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "both";
|
||||
@@ -244,22 +261,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
@@ -278,14 +298,31 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("mirror", stored!.Log.Backend);
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private sealed class StubVerificationCache : IAttestorVerificationCache
|
||||
{
|
||||
public List<string> InvalidatedSubjects { get; } = new();
|
||||
|
||||
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)
|
||||
{
|
||||
InvalidatedSubjects.Add(subject);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "keyless",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorSubmissionValidatorHardeningTests
|
||||
{
|
||||
private static readonly DefaultDsseCanonicalizer Canonicalizer = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ThrowsWhenPayloadExceedsLimit()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints(
|
||||
maxPayloadBytes: 16,
|
||||
maxSignatures: 6,
|
||||
maxCertificateChainEntries: 6);
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
|
||||
var oversized = CreateValidRequest(payloadSize: 32);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(oversized));
|
||||
Assert.Equal("payload_too_large", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ThrowsWhenCertificateChainTooLong()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints(
|
||||
maxPayloadBytes: 2048,
|
||||
maxSignatures: 6,
|
||||
maxCertificateChainEntries: 2);
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
|
||||
var request = CreateValidRequest(certificateCount: 3);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
|
||||
Assert.Equal("certificate_chain_too_long", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FuzzedInputs_DoNotCrash()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints();
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
var random = new Random(0x715f_c3a1);
|
||||
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var mutated = CreateValidRequest();
|
||||
Mutate(mutated, random);
|
||||
|
||||
try
|
||||
{
|
||||
await validator.ValidateAsync(mutated);
|
||||
}
|
||||
catch (AttestorValidationException)
|
||||
{
|
||||
// Expected for malformed inputs.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(int payloadSize = 16, int signatureCount = 1, int certificateCount = 0)
|
||||
{
|
||||
if (payloadSize <= 0)
|
||||
{
|
||||
payloadSize = 1;
|
||||
}
|
||||
|
||||
var payload = new byte[payloadSize];
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] = (byte)'A';
|
||||
}
|
||||
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
for (var i = 0; i < Math.Max(1, signatureCount); i++)
|
||||
{
|
||||
var signatureBytes = Encoding.UTF8.GetBytes($"sig-{i}");
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = $"sig-{i}",
|
||||
Signature = Convert.ToBase64String(signatureBytes)
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < certificateCount; i++)
|
||||
{
|
||||
request.Bundle.CertificateChain.Add($"-----BEGIN CERTIFICATE-----FAKE{i}-----END CERTIFICATE-----");
|
||||
}
|
||||
|
||||
var canonical = Canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void Mutate(AttestorSubmissionRequest request, Random random)
|
||||
{
|
||||
switch (random.Next(0, 7))
|
||||
{
|
||||
case 0:
|
||||
request.Bundle.Dsse.PayloadBase64 = RandomString(random, random.Next(0, 32));
|
||||
break;
|
||||
case 1:
|
||||
request.Meta.Artifact.Sha256 = RandomString(random, random.Next(0, 70));
|
||||
break;
|
||||
case 2:
|
||||
request.Bundle.Dsse.Signatures.Clear();
|
||||
break;
|
||||
case 3:
|
||||
request.Meta.BundleSha256 = RandomString(random, random.Next(10, 40));
|
||||
break;
|
||||
case 4:
|
||||
request.Meta.LogPreference = "invalid-" + random.Next(1, 9999);
|
||||
break;
|
||||
case 5:
|
||||
request.Bundle.CertificateChain.Add(RandomString(random, random.Next(5, 25)));
|
||||
break;
|
||||
default:
|
||||
request.Bundle.Dsse.PayloadType = RandomString(random, random.Next(0, 20));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RandomString(Random random, int length)
|
||||
{
|
||||
const string alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
||||
if (length <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Span<char> buffer = stackalloc char[length];
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
buffer[i] = alphabet[random.Next(alphabet.Length)];
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,267 +1,610 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorVerificationServiceTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.True(verifyResult.Ok);
|
||||
Assert.Equal(response.Uuid, verifyResult.Uuid);
|
||||
Assert.Empty(verifyResult.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FlagsTamperedBundle()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.example/",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var tamperedBundle = CloneBundle(submission.Bundle);
|
||||
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = tamperedBundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
|
||||
using (var hmac = new HMACSHA256(hmacSecret))
|
||||
{
|
||||
var signature = hmac.ComputeHash(preAuth);
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "kms-test",
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
});
|
||||
}
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
|
||||
{
|
||||
var clone = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = source.Dsse.PayloadType,
|
||||
PayloadBase64 = source.Dsse.PayloadBase64
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var certificate in source.CertificateChain)
|
||||
{
|
||||
clone.CertificateChain.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var signature in source.Dsse.Signatures)
|
||||
{
|
||||
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Signature = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using StellaOps.Attestor.Verify;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorVerificationServiceTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.True(verifyResult.Ok);
|
||||
Assert.Equal(response.Uuid, verifyResult.Uuid);
|
||||
Assert.Contains("witness_missing", verifyResult.Issues);
|
||||
Assert.Contains("policy_warn:transparency", verifyResult.Issues);
|
||||
Assert.NotNull(verifyResult.Report);
|
||||
Assert.Equal(VerificationSectionStatus.Warn, verifyResult.Report!.OverallStatus);
|
||||
Assert.False(verifyResult.Report.Transparency.WitnessPresent);
|
||||
Assert.Equal("missing", verifyResult.Report.Transparency.WitnessStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FlagsTamperedBundle()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.example/",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var tamperedBundle = CloneBundle(submission.Bundle);
|
||||
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = tamperedBundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.NotNull(result.Report);
|
||||
Assert.Equal(VerificationSectionStatus.Fail, result.Report!.Signatures.Status);
|
||||
Assert.Contains("signature_invalid", result.Report!.Signatures.Issues);
|
||||
}
|
||||
|
||||
private sealed class NullVerificationCache : 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;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
|
||||
using (var hmac = new HMACSHA256(hmacSecret))
|
||||
{
|
||||
var signature = hmac.ComputeHash(preAuth);
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "kms-test",
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
});
|
||||
}
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
|
||||
{
|
||||
var clone = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = source.Dsse.PayloadType,
|
||||
PayloadBase64 = source.Dsse.PayloadBase64
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var certificate in source.CertificateChain)
|
||||
{
|
||||
clone.CertificateChain.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var signature in source.Dsse.Signatures)
|
||||
{
|
||||
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Signature = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_OfflineSkipsProofRefreshWhenMissing()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "offline-test",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "deadbeef",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = "abc123",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/offline-test"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true
|
||||
});
|
||||
|
||||
Assert.Contains("proof_missing", result.Issues);
|
||||
Assert.Equal(0, rekorClient.ProofRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_OfflineUsesImportedProof()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\"}");
|
||||
var bundleHashBytes = SHA256.HashData(canonicalBytes);
|
||||
var bundleSha = Convert.ToHexString(bundleHashBytes).ToLowerInvariant();
|
||||
var siblingBytes = SHA256.HashData(Encoding.UTF8.GetBytes("sibling-node"));
|
||||
var rootHashBytes = ComputeMerkleNode(siblingBytes, bundleHashBytes);
|
||||
var rootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "offline-proof-test",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "cafebabe",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Proof = new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = "rekor.stellaops.test",
|
||||
Size = 2,
|
||||
RootHash = rootHash,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = bundleSha,
|
||||
Path = new[] { $"L:{Convert.ToHexString(siblingBytes).ToLowerInvariant()}" }
|
||||
}
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/offline-proof-test"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true
|
||||
});
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.DoesNotContain("proof_missing", result.Issues);
|
||||
Assert.Equal(0, rekorClient.ProofRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsWhenWitnessRootMismatch()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.witness.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
},
|
||||
Verification = new AttestorOptions.VerificationOptions
|
||||
{
|
||||
RequireWitnessEndorsement = true
|
||||
},
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.stellaops.test"
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient
|
||||
{
|
||||
DefaultObservation = new TransparencyWitnessObservation
|
||||
{
|
||||
Aggregator = "stub-aggregator",
|
||||
Status = "endorsed",
|
||||
RootHash = "mismatched",
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.StartsWith("witness_root_mismatch", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(result.Report!.Transparency.WitnessPresent);
|
||||
Assert.False(result.Report.Transparency.WitnessMatchesRoot);
|
||||
Assert.Equal("stub-aggregator", result.Report.Transparency.WitnessAggregator);
|
||||
Assert.Equal("endorsed", result.Report.Transparency.WitnessStatus);
|
||||
Assert.NotEmpty(witnessClient.Requests);
|
||||
}
|
||||
|
||||
private static byte[] ComputeMerkleNode(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private sealed class RecordingRekorClient : IRekorClient
|
||||
{
|
||||
public int ProofRequests { get; private set; }
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ProofRequests++;
|
||||
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = string.Empty,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class BulkVerificationContractsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryBuildJob_ReturnsError_WhenItemsMissing()
|
||||
{
|
||||
var options = new AttestorOptions();
|
||||
var context = new BulkVerificationJobContext();
|
||||
|
||||
var success = BulkVerificationContracts.TryBuildJob(null, options, context, out var job, out var error);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(job);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildJob_AppliesDefaults()
|
||||
{
|
||||
var options = new AttestorOptions
|
||||
{
|
||||
Quotas = new AttestorOptions.QuotaOptions
|
||||
{
|
||||
Bulk = new AttestorOptions.BulkVerificationQuotaOptions
|
||||
{
|
||||
MaxItemsPerJob = 10
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var dto = new BulkVerificationRequestDto
|
||||
{
|
||||
PolicyVersion = "policy@1",
|
||||
RefreshProof = true,
|
||||
Items = new[]
|
||||
{
|
||||
new BulkVerificationRequestItemDto
|
||||
{
|
||||
Subject = "pkg:docker/example",
|
||||
EnvelopeId = "envelope-1"
|
||||
},
|
||||
new BulkVerificationRequestItemDto
|
||||
{
|
||||
Uuid = "rekor-123",
|
||||
RefreshProof = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var context = new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
RequestedBy = "user-1"
|
||||
};
|
||||
|
||||
var success = BulkVerificationContracts.TryBuildJob(dto, options, context, out var job, out var error);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(job);
|
||||
Assert.Equal(2, job!.Items.Count);
|
||||
Assert.Equal("policy@1", job.Items[0].Request.PolicyVersion);
|
||||
Assert.True(job.Items[0].Request.RefreshProof);
|
||||
Assert.False(job.Items[1].Request.RefreshProof);
|
||||
Assert.Equal("tenant-a", job.Context.Tenant);
|
||||
Assert.Equal(BulkVerificationJobStatus.Queued, job.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Bulk;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class BulkVerificationWorkerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessJobAsync_CompletesAllItems()
|
||||
{
|
||||
var jobStore = new InMemoryBulkVerificationJobStore();
|
||||
var verificationService = new StubVerificationService();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
BulkVerification = new AttestorOptions.BulkVerificationOptions
|
||||
{
|
||||
WorkerPollSeconds = 1,
|
||||
ItemDelayMilliseconds = 0
|
||||
},
|
||||
Quotas = new AttestorOptions.QuotaOptions
|
||||
{
|
||||
Bulk = new AttestorOptions.BulkVerificationQuotaOptions
|
||||
{
|
||||
MaxItemsPerJob = 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var job = new BulkVerificationJob
|
||||
{
|
||||
Id = "job-1",
|
||||
Items = new List<BulkVerificationJobItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Index = 0,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Subject = "pkg:docker/example",
|
||||
EnvelopeId = "env-1",
|
||||
PolicyVersion = "policy"
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Index = 1,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Uuid = "rekor-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await jobStore.CreateAsync(job);
|
||||
var worker = new BulkVerificationWorker(
|
||||
jobStore,
|
||||
verificationService,
|
||||
metrics,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<BulkVerificationWorker>.Instance);
|
||||
|
||||
var acquired = await jobStore.TryAcquireAsync();
|
||||
Assert.NotNull(acquired);
|
||||
|
||||
await worker.ProcessJobAsync(acquired!, CancellationToken.None);
|
||||
|
||||
var stored = await jobStore.GetAsync(job.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(BulkVerificationJobStatus.Completed, stored!.Status);
|
||||
Assert.Equal(2, stored.ProcessedCount);
|
||||
Assert.Equal(2, stored.SucceededCount);
|
||||
Assert.Equal(0, stored.FailedCount);
|
||||
Assert.All(stored.Items, item => Assert.Equal(BulkVerificationItemStatus.Succeeded, item.Status));
|
||||
}
|
||||
|
||||
private sealed class StubVerificationService : IAttestorVerificationService
|
||||
{
|
||||
public Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new AttestorVerificationResult
|
||||
{
|
||||
Ok = true,
|
||||
Uuid = request.Uuid ?? "uuid-placeholder",
|
||||
Status = "verified"
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryBulkVerificationJobStore : IBulkVerificationJobStore
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, BulkVerificationJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
job.Version = 0;
|
||||
job.Status = BulkVerificationJobStatus.Queued;
|
||||
_jobs[job.Id] = Clone(job);
|
||||
return Task.FromResult(Clone(job));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return Task.FromResult(_jobs.TryGetValue(jobId, out var stored) ? Clone(stored) : null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var entry in _jobs.Values.OrderBy(job => job.CreatedAt))
|
||||
{
|
||||
if (entry.Status != BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Status = BulkVerificationJobStatus.Running;
|
||||
entry.StartedAt = DateTimeOffset.UtcNow;
|
||||
entry.Version += 1;
|
||||
_jobs[entry.Id] = Clone(entry);
|
||||
return Task.FromResult<BulkVerificationJob?>(Clone(entry)!);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<BulkVerificationJob?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_jobs.TryGetValue(job.Id, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (current.Version != job.Version)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
job.Version += 1;
|
||||
_jobs[job.Id] = Clone(job);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountQueuedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var count = _jobs.Values.Count(job => job.Status == BulkVerificationJobStatus.Queued);
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
private static BulkVerificationJob Clone(BulkVerificationJob job)
|
||||
{
|
||||
var context = job.Context ?? new BulkVerificationJobContext();
|
||||
return new BulkVerificationJob
|
||||
{
|
||||
Id = job.Id,
|
||||
Version = job.Version,
|
||||
Status = job.Status,
|
||||
CreatedAt = job.CreatedAt,
|
||||
StartedAt = job.StartedAt,
|
||||
CompletedAt = job.CompletedAt,
|
||||
Context = new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = context.Tenant,
|
||||
RequestedBy = context.RequestedBy,
|
||||
ClientId = context.ClientId,
|
||||
Scopes = context.Scopes?.ToList() ?? new List<string>()
|
||||
},
|
||||
ProcessedCount = job.ProcessedCount,
|
||||
SucceededCount = job.SucceededCount,
|
||||
FailedCount = job.FailedCount,
|
||||
FailureReason = job.FailureReason,
|
||||
Items = (job.Items ?? Array.Empty<BulkVerificationJobItem>()).Select(CloneItem).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static BulkVerificationJobItem CloneItem(BulkVerificationJobItem item)
|
||||
{
|
||||
var request = item.Request ?? new BulkVerificationItemRequest();
|
||||
return new BulkVerificationJobItem
|
||||
{
|
||||
Index = item.Index,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Uuid = request.Uuid,
|
||||
ArtifactSha256 = request.ArtifactSha256,
|
||||
Subject = request.Subject,
|
||||
EnvelopeId = request.EnvelopeId,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
RefreshProof = request.RefreshProof
|
||||
},
|
||||
Status = item.Status,
|
||||
StartedAt = item.StartedAt,
|
||||
CompletedAt = item.CompletedAt,
|
||||
Result = item.Result is null ? null : new AttestorVerificationResult
|
||||
{
|
||||
Ok = item.Result.Ok,
|
||||
Uuid = item.Result.Uuid,
|
||||
Index = item.Result.Index,
|
||||
LogUrl = item.Result.LogUrl,
|
||||
CheckedAt = item.Result.CheckedAt,
|
||||
Status = item.Result.Status,
|
||||
Issues = item.Result.Issues.ToArray(),
|
||||
Report = item.Result.Report
|
||||
},
|
||||
Error = item.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class CachedAttestorVerificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsCachedResult_OnRepeatedCalls()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123",
|
||||
PolicyVersion = "policy-v1"
|
||||
};
|
||||
|
||||
var first = await service.VerifyAsync(request);
|
||||
var second = await service.VerifyAsync(request);
|
||||
|
||||
Assert.True(first.Ok);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(1, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_BypassesCache_WhenRefreshProofRequested()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123",
|
||||
PolicyVersion = "policy-v1",
|
||||
RefreshProof = true
|
||||
};
|
||||
|
||||
var first = await service.VerifyAsync(request);
|
||||
var second = await service.VerifyAsync(request);
|
||||
|
||||
Assert.True(first.Ok);
|
||||
Assert.True(second.Ok);
|
||||
Assert.Equal(2, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_BypassesCache_WhenDescriptorIncomplete()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123"
|
||||
};
|
||||
|
||||
await service.VerifyAsync(request);
|
||||
await service.VerifyAsync(request);
|
||||
|
||||
Assert.Equal(2, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
private sealed class StubVerificationService : IAttestorVerificationService
|
||||
{
|
||||
public int VerifyCallCount { get; private set; }
|
||||
|
||||
public Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
VerifyCallCount++;
|
||||
return Task.FromResult<AttestorVerificationResult>(new()
|
||||
{
|
||||
Ok = true,
|
||||
Uuid = "uuid"
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class HttpTransparencyWitnessClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_CachesSuccessfulResponses()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
aggregator = "aggregator.test",
|
||||
status = "endorsed",
|
||||
rootHash = "abc123",
|
||||
statement = "test-statement",
|
||||
signature = new { keyId = "sig-key", value = "sig-value" },
|
||||
timestamp = "2025-11-02T00:00:00Z"
|
||||
});
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload)
|
||||
};
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test",
|
||||
CacheTtlSeconds = 60
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-1",
|
||||
"primary",
|
||||
new Uri("https://rekor.example"),
|
||||
"abc123");
|
||||
|
||||
var first = await sut.GetObservationAsync(request);
|
||||
var second = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal("aggregator.test", first!.Aggregator);
|
||||
Assert.Equal("endorsed", first.Status);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_ReturnsErrorObservation_OnNonSuccess()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway));
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test"
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-2",
|
||||
"primary",
|
||||
new Uri("https://rekor.example"),
|
||||
"root-hash");
|
||||
|
||||
var observation = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(observation);
|
||||
Assert.Equal("primary", observation!.Aggregator);
|
||||
Assert.Equal("http_502", observation.Status);
|
||||
Assert.Equal("root-hash", observation.RootHash);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_ReturnsCachedErrorObservation_OnException()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"));
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test",
|
||||
CacheTtlSeconds = 30
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-3",
|
||||
"mirror",
|
||||
new Uri("https://rekor.mirror"),
|
||||
null);
|
||||
|
||||
var first = await sut.GetObservationAsync(request);
|
||||
var second = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal("mirror", first!.Aggregator);
|
||||
Assert.Equal("HttpRequestException", first.Status);
|
||||
Assert.Equal("boom", first.Error);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(_handler(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class LiveDedupeStoreTests
|
||||
{
|
||||
private const string Category = "LiveTTL";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Mongo_dedupe_document_expires_via_ttl_index()
|
||||
{
|
||||
var mongoUri = Environment.GetEnvironmentVariable("ATTESTOR_LIVE_MONGO_URI");
|
||||
if (string.IsNullOrWhiteSpace(mongoUri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var mongoUrl = new MongoUrl(mongoUri);
|
||||
var client = new MongoClient(mongoUrl);
|
||||
var databaseName = $"{(string.IsNullOrWhiteSpace(mongoUrl.DatabaseName) ? "attestor_live_ttl" : mongoUrl.DatabaseName)}_{Guid.NewGuid():N}";
|
||||
var database = client.GetDatabase(databaseName);
|
||||
var collection = database.GetCollection<MongoAttestorDedupeStore.AttestorDedupeDocument>("dedupe");
|
||||
|
||||
try
|
||||
{
|
||||
var store = new MongoAttestorDedupeStore(collection, TimeProvider.System);
|
||||
|
||||
var indexes = await (await collection.Indexes.ListAsync()).ToListAsync();
|
||||
Assert.Contains(indexes, doc => doc.TryGetElement("name", out var element) && element.Value == "dedupe_ttl");
|
||||
|
||||
var bundle = Guid.NewGuid().ToString("N");
|
||||
var ttl = TimeSpan.FromSeconds(20);
|
||||
await store.SetAsync(bundle, "rekor-live", ttl);
|
||||
|
||||
var filter = Builders<MongoAttestorDedupeStore.AttestorDedupeDocument>.Filter.Eq(x => x.Key, $"bundle:{bundle}");
|
||||
Assert.True(await collection.Find(filter).AnyAsync(), "Seed document was not written.");
|
||||
|
||||
var deadline = DateTime.UtcNow + ttl + TimeSpan.FromMinutes(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (!await collection.Find(filter).AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
throw new TimeoutException("TTL document remained in MongoDB after waiting for expiry.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DropDatabaseAsync(databaseName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Redis_dedupe_entry_sets_time_to_live()
|
||||
{
|
||||
var redisConnection = Environment.GetEnvironmentVariable("ATTESTOR_LIVE_REDIS_URI");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = redisConnection,
|
||||
DedupePrefix = "attestor:ttl:live:"
|
||||
}
|
||||
});
|
||||
|
||||
var multiplexer = await ConnectionMultiplexer.ConnectAsync(redisConnection);
|
||||
try
|
||||
{
|
||||
var store = new RedisAttestorDedupeStore(multiplexer, options);
|
||||
var database = multiplexer.GetDatabase();
|
||||
|
||||
var bundle = Guid.NewGuid().ToString("N");
|
||||
var ttl = TimeSpan.FromSeconds(30);
|
||||
|
||||
await store.SetAsync(bundle, "rekor-redis", ttl);
|
||||
var value = await store.TryGetExistingAsync(bundle);
|
||||
Assert.Equal("rekor-redis", value);
|
||||
|
||||
var redisKey = (RedisKey)(options.Value.Redis.DedupePrefix + $"bundle:{bundle}");
|
||||
var timeToLive = await database.KeyTimeToLiveAsync(redisKey);
|
||||
|
||||
Assert.NotNull(timeToLive);
|
||||
Assert.InRange(timeToLive!.Value, TimeSpan.Zero, ttl);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await multiplexer.CloseAsync();
|
||||
await multiplexer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -11,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="3.1.3" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
@@ -22,5 +20,6 @@
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,54 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
|
||||
|
||||
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = _entries.Values.FirstOrDefault(e => string.Equals(e.BundleSha256, bundleSha256, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(rekorUuid, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries[entry.RekorUuid] = entry;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
public List<AttestorAuditRecord> Records { get; } = new();
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
|
||||
private readonly Dictionary<string, string> _bundleIndex = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _sync = new();
|
||||
|
||||
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? uuid;
|
||||
lock (_sync)
|
||||
{
|
||||
_bundleIndex.TryGetValue(bundleSha256, out uuid);
|
||||
}
|
||||
|
||||
if (uuid is not null && _entries.TryGetValue(uuid, out var entry))
|
||||
{
|
||||
return Task.FromResult<AttestorEntry?>(entry);
|
||||
}
|
||||
|
||||
return Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(rekorUuid, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<AttestorEntry> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = _entries.Values.ToList();
|
||||
}
|
||||
|
||||
var entries = snapshot
|
||||
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_bundleIndex.TryGetValue(entry.BundleSha256, out var existingUuid) &&
|
||||
!string.Equals(existingUuid, entry.RekorUuid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Bundle SHA '{entry.BundleSha256}' already exists.");
|
||||
}
|
||||
|
||||
if (_entries.TryGetValue(entry.RekorUuid, out var existing) &&
|
||||
!string.Equals(existing.BundleSha256, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bundleIndex.Remove(existing.BundleSha256);
|
||||
}
|
||||
|
||||
_entries[entry.RekorUuid] = entry;
|
||||
_bundleIndex[entry.BundleSha256] = entry.RekorUuid;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestorEntryQueryResult> QueryAsync(AttestorEntryQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var pageSize = query.PageSize <= 0 ? 50 : Math.Min(query.PageSize, 200);
|
||||
|
||||
List<AttestorEntry> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = _entries.Values.ToList();
|
||||
}
|
||||
|
||||
IEnumerable<AttestorEntry> sequence = snapshot;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Subject))
|
||||
{
|
||||
var subject = query.Subject;
|
||||
sequence = sequence.Where(e =>
|
||||
string.Equals(e.Artifact.Sha256, subject, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(e.Artifact.ImageDigest, subject, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(e.Artifact.SubjectUri, subject, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Type))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.Artifact.Kind, query.Type, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Issuer))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.SignerIdentity.SubjectAlternativeName, query.Issuer, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Scope))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.SignerIdentity.Issuer, query.Scope, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.CreatedAfter is { } createdAfter)
|
||||
{
|
||||
sequence = sequence.Where(e => e.CreatedAt >= createdAfter);
|
||||
}
|
||||
|
||||
if (query.CreatedBefore is { } createdBefore)
|
||||
{
|
||||
sequence = sequence.Where(e => e.CreatedAt <= createdBefore);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ContinuationToken))
|
||||
{
|
||||
var continuation = AttestorEntryContinuationToken.Parse(query.ContinuationToken);
|
||||
sequence = sequence.Where(e =>
|
||||
{
|
||||
var createdAt = e.CreatedAt;
|
||||
if (createdAt < continuation.CreatedAt)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (createdAt > continuation.CreatedAt)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.CompareOrdinal(e.RekorUuid, continuation.RekorUuid) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
var ordered = sequence
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenBy(e => e.RekorUuid, StringComparer.Ordinal);
|
||||
|
||||
var page = ordered.Take(pageSize + 1).ToList();
|
||||
AttestorEntry? next = null;
|
||||
if (page.Count > pageSize)
|
||||
{
|
||||
next = page[^1];
|
||||
page.RemoveAt(page.Count - 1);
|
||||
}
|
||||
|
||||
var result = new AttestorEntryQueryResult
|
||||
{
|
||||
Items = page,
|
||||
ContinuationToken = next is null
|
||||
? null
|
||||
: AttestorEntryContinuationToken.Encode(next.CreatedAt, next.RekorUuid)
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
public List<AttestorAuditRecord> Records { get; } = new();
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorArchiveStore : IAttestorArchiveStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorArchiveBundle> _bundles = new();
|
||||
|
||||
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles[bundle.BundleSha256] = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = bundle.RekorUuid,
|
||||
ArtifactSha256 = bundle.ArtifactSha256,
|
||||
BundleSha256 = bundle.BundleSha256,
|
||||
CanonicalBundleJson = bundle.CanonicalBundleJson,
|
||||
ProofJson = bundle.ProofJson,
|
||||
Metadata = bundle.Metadata
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestorArchiveBundle?> GetBundleAsync(string bundleSha256, string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_bundles.TryGetValue(bundleSha256, out var bundle))
|
||||
{
|
||||
return Task.FromResult<AttestorArchiveBundle?>(bundle);
|
||||
}
|
||||
|
||||
return Task.FromResult<AttestorArchiveBundle?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
|
||||
namespace StellaOps.Attestor.Tests.Support;
|
||||
|
||||
internal sealed class TestTransparencyWitnessClient : ITransparencyWitnessClient
|
||||
{
|
||||
public List<TransparencyWitnessRequest> Requests { get; } = new();
|
||||
|
||||
public TransparencyWitnessObservation? DefaultObservation { get; set; }
|
||||
|
||||
public Func<TransparencyWitnessRequest, TransparencyWitnessObservation?>? OnRequest { get; set; }
|
||||
|
||||
public Task<TransparencyWitnessObservation?> GetObservationAsync(TransparencyWitnessRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
if (OnRequest is not null)
|
||||
{
|
||||
return Task.FromResult(OnRequest(request));
|
||||
}
|
||||
|
||||
return Task.FromResult(DefaultObservation);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAttestationSigningService : IAttestationSigningService
|
||||
{
|
||||
public List<AttestationSignRequest> Requests { get; } = new();
|
||||
|
||||
public AttestationSignResult Result { get; set; } = new();
|
||||
|
||||
public Func<AttestationSignRequest, SubmissionContext, AttestationSignResult>? OnSign { get; set; }
|
||||
|
||||
public Task<AttestationSignResult> SignAsync(AttestationSignRequest request, SubmissionContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
if (OnSign is not null)
|
||||
{
|
||||
return Task.FromResult(OnSign(request, context));
|
||||
}
|
||||
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestBulkVerificationJobStore : IBulkVerificationJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BulkVerificationJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Func<Task<BulkVerificationJob?>>? OnTryAcquireAsync { get; set; }
|
||||
|
||||
public Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(job.Id) ? Guid.NewGuid().ToString("N") : job.Id;
|
||||
job.Id = id;
|
||||
_jobs[id] = job;
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (OnTryAcquireAsync is not null)
|
||||
{
|
||||
return OnTryAcquireAsync();
|
||||
}
|
||||
|
||||
foreach (var job in _jobs.Values)
|
||||
{
|
||||
if (job.Status == BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
return Task.FromResult<BulkVerificationJob?>(job);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<BulkVerificationJob?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.Id] = job;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<int> CountQueuedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var job in _jobs.Values)
|
||||
{
|
||||
if (job.Status == BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
internal sealed class AttestationExportRequestDto
|
||||
{
|
||||
public List<string>? Uuids { get; init; }
|
||||
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? Type { get; init; }
|
||||
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
public string? Scope { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
public int? Limit { get; init; }
|
||||
|
||||
public string? ContinuationToken { get; init; }
|
||||
|
||||
public bool TryToDomain(out AttestorBundleExportRequest request, out IResult? error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
if (Limit is { } limit && limit <= 0)
|
||||
{
|
||||
request = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "`limit` must be greater than zero.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (CreatedAfter.HasValue && CreatedBefore.HasValue && CreatedAfter > CreatedBefore)
|
||||
{
|
||||
request = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "`createdAfter` must be earlier than `createdBefore`.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ContinuationToken) && !AttestorEntryContinuationToken.TryParse(ContinuationToken, out _))
|
||||
{
|
||||
request = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid continuation token.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ContinuationToken) && Uuids is { Count: > 0 })
|
||||
{
|
||||
request = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "`continuationToken` cannot be combined with explicit `uuids`.");
|
||||
return false;
|
||||
}
|
||||
|
||||
int? sanitizedLimit = Limit.HasValue ? Math.Clamp(Limit.Value, 1, 200) : null;
|
||||
|
||||
IReadOnlyList<string> uuids = Array.Empty<string>();
|
||||
if (Uuids is { Count: > 0 } uuidList)
|
||||
{
|
||||
uuids = uuidList
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
request = new AttestorBundleExportRequest
|
||||
{
|
||||
Uuids = uuids,
|
||||
Subject = Subject,
|
||||
Type = Type,
|
||||
Issuer = Issuer,
|
||||
Scope = Scope,
|
||||
CreatedAfter = CreatedAfter,
|
||||
CreatedBefore = CreatedBefore,
|
||||
Limit = sanitizedLimit,
|
||||
ContinuationToken = ContinuationToken
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
internal static class AttestationListContracts
|
||||
{
|
||||
private const int DefaultPageSize = 50;
|
||||
private const int MaxPageSize = 200;
|
||||
|
||||
public static bool TryBuildQuery(HttpRequest request, out AttestorEntryQuery query, out IResult? error)
|
||||
{
|
||||
error = null;
|
||||
var collection = request.Query;
|
||||
|
||||
var subject = GetOptional(collection, "subject");
|
||||
var type = GetOptional(collection, "type");
|
||||
var issuer = GetOptional(collection, "issuer");
|
||||
var scope = GetOptional(collection, "scope");
|
||||
var continuationToken = GetOptional(collection, "continuationToken");
|
||||
|
||||
var pageSize = DefaultPageSize;
|
||||
if (collection.TryGetValue("pageSize", out var pageSizeValues) && !StringValues.IsNullOrEmpty(pageSizeValues))
|
||||
{
|
||||
if (!int.TryParse(pageSizeValues.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out pageSize) || pageSize <= 0)
|
||||
{
|
||||
query = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid pageSize query parameter.");
|
||||
return false;
|
||||
}
|
||||
|
||||
pageSize = pageSize > MaxPageSize ? MaxPageSize : pageSize;
|
||||
}
|
||||
|
||||
DateTimeOffset? createdAfter = null;
|
||||
if (collection.TryGetValue("createdAfter", out var createdAfterValues) && !StringValues.IsNullOrEmpty(createdAfterValues))
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(createdAfterValues.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedAfter))
|
||||
{
|
||||
query = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid createdAfter query parameter.");
|
||||
return false;
|
||||
}
|
||||
|
||||
createdAfter = parsedAfter;
|
||||
}
|
||||
|
||||
DateTimeOffset? createdBefore = null;
|
||||
if (collection.TryGetValue("createdBefore", out var createdBeforeValues) && !StringValues.IsNullOrEmpty(createdBeforeValues))
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(createdBeforeValues.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedBefore))
|
||||
{
|
||||
query = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid createdBefore query parameter.");
|
||||
return false;
|
||||
}
|
||||
|
||||
createdBefore = parsedBefore;
|
||||
}
|
||||
|
||||
if (createdAfter.HasValue && createdBefore.HasValue && createdAfter > createdBefore)
|
||||
{
|
||||
query = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "`createdAfter` must be earlier than `createdBefore`.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (continuationToken is not null && !AttestorEntryContinuationToken.TryParse(continuationToken, out _))
|
||||
{
|
||||
query = default!;
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid continuation token.");
|
||||
return false;
|
||||
}
|
||||
|
||||
query = new AttestorEntryQuery
|
||||
{
|
||||
Subject = subject,
|
||||
Type = type,
|
||||
Issuer = issuer,
|
||||
Scope = scope,
|
||||
CreatedAfter = createdAfter,
|
||||
CreatedBefore = createdBefore,
|
||||
PageSize = pageSize,
|
||||
ContinuationToken = continuationToken
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetOptional(IQueryCollection collection, string key)
|
||||
{
|
||||
if (!collection.TryGetValue(key, out var values) || StringValues.IsNullOrEmpty(values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return values.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AttestationListResponseDto
|
||||
{
|
||||
public required IReadOnlyList<AttestationListItemDto> Items { get; init; }
|
||||
|
||||
public string? ContinuationToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationListItemDto
|
||||
{
|
||||
public required string Uuid { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public required AttestationArtifactDto Artifact { get; init; }
|
||||
public required AttestationSignerDto Signer { get; init; }
|
||||
public required AttestationLogDto Log { get; init; }
|
||||
public AttestationLogDto? Mirror { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationArtifactDto
|
||||
{
|
||||
public required string Sha256 { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public string? ImageDigest { get; init; }
|
||||
public string? SubjectUri { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationSignerDto
|
||||
{
|
||||
public required string Mode { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationLogDto
|
||||
{
|
||||
public required string Backend { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public long? Index { get; init; }
|
||||
public string? Status { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
public sealed class AttestationSignRequestDto
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public List<string>? CertificateChain { get; set; }
|
||||
|
||||
public AttestationSignArtifactDto Artifact { get; set; } = new();
|
||||
|
||||
public string? LogPreference { get; set; }
|
||||
|
||||
public bool? Archive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AttestationSignArtifactDto
|
||||
{
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string? ImageDigest { get; set; }
|
||||
|
||||
public string? SubjectUri { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AttestationSignResponseDto
|
||||
{
|
||||
public AttestorSubmissionRequest.SubmissionBundle Bundle { get; init; } = new();
|
||||
|
||||
public AttestorSubmissionRequest.SubmissionMeta Meta { get; init; } = new();
|
||||
|
||||
public AttestationSignKeyDto Key { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class AttestationSignKeyDto
|
||||
{
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
|
||||
public string Provider { get; init; } = string.Empty;
|
||||
|
||||
public string SignedAt { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
internal static class BulkVerificationContracts
|
||||
{
|
||||
public static bool TryBuildJob(
|
||||
BulkVerificationRequestDto? requestDto,
|
||||
AttestorOptions options,
|
||||
BulkVerificationJobContext context,
|
||||
out BulkVerificationJob? job,
|
||||
out IResult? error)
|
||||
{
|
||||
job = null;
|
||||
error = null;
|
||||
|
||||
if (requestDto is null || requestDto.Items is null || requestDto.Items.Length == 0)
|
||||
{
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "At least one verification item is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var maxItems = Math.Max(1, options.Quotas.Bulk.MaxItemsPerJob);
|
||||
if (requestDto.Items.Length > maxItems)
|
||||
{
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: $"Too many items; maximum allowed is {maxItems}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var defaultPolicy = Normalize(requestDto.PolicyVersion);
|
||||
var defaultRefresh = requestDto.RefreshProof ?? false;
|
||||
|
||||
var requests = new List<BulkVerificationJobItem>(requestDto.Items.Length);
|
||||
|
||||
for (var index = 0; index < requestDto.Items.Length; index++)
|
||||
{
|
||||
var itemDto = requestDto.Items[index];
|
||||
if (itemDto is null)
|
||||
{
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: $"Item at index {index} is null.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var request = new BulkVerificationItemRequest
|
||||
{
|
||||
Uuid = Normalize(itemDto.Uuid),
|
||||
ArtifactSha256 = Normalize(itemDto.ArtifactSha256),
|
||||
Subject = Normalize(itemDto.Subject),
|
||||
EnvelopeId = Normalize(itemDto.EnvelopeId),
|
||||
PolicyVersion = Normalize(itemDto.PolicyVersion) ?? defaultPolicy,
|
||||
RefreshProof = itemDto.RefreshProof ?? defaultRefresh
|
||||
};
|
||||
|
||||
if (!IsRequestValid(request))
|
||||
{
|
||||
error = Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: $"Item {index} must define uuid, artifactSha256, or subject+envelopeId.");
|
||||
return false;
|
||||
}
|
||||
|
||||
requests.Add(new BulkVerificationJobItem
|
||||
{
|
||||
Index = index,
|
||||
Request = request
|
||||
});
|
||||
}
|
||||
|
||||
job = new BulkVerificationJob
|
||||
{
|
||||
Context = context,
|
||||
Items = requests
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static object MapJob(BulkVerificationJob job)
|
||||
{
|
||||
return new BulkVerificationJobDto
|
||||
{
|
||||
Id = job.Id,
|
||||
Status = job.Status.ToString().ToLowerInvariant(),
|
||||
CreatedAt = job.CreatedAt.ToString("O"),
|
||||
StartedAt = job.StartedAt?.ToString("O"),
|
||||
CompletedAt = job.CompletedAt?.ToString("O"),
|
||||
Processed = job.ProcessedCount,
|
||||
Succeeded = job.SucceededCount,
|
||||
Failed = job.FailedCount,
|
||||
Total = job.Items.Count,
|
||||
FailureReason = job.FailureReason,
|
||||
Items = job.Items
|
||||
.OrderBy(item => item.Index)
|
||||
.Select(MapItem)
|
||||
.ToArray(),
|
||||
Context = new BulkVerificationJobContextDto
|
||||
{
|
||||
Tenant = job.Context.Tenant,
|
||||
RequestedBy = job.Context.RequestedBy,
|
||||
ClientId = job.Context.ClientId,
|
||||
Scopes = job.Context.Scopes?.ToArray() ?? Array.Empty<string>()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BulkVerificationJobItemDto MapItem(BulkVerificationJobItem item)
|
||||
{
|
||||
return new BulkVerificationJobItemDto
|
||||
{
|
||||
Index = item.Index,
|
||||
Status = item.Status.ToString().ToLowerInvariant(),
|
||||
StartedAt = item.StartedAt?.ToString("O"),
|
||||
CompletedAt = item.CompletedAt?.ToString("O"),
|
||||
Error = item.Error,
|
||||
Request = new BulkVerificationItemRequestDto
|
||||
{
|
||||
Uuid = item.Request.Uuid,
|
||||
ArtifactSha256 = item.Request.ArtifactSha256,
|
||||
Subject = item.Request.Subject,
|
||||
EnvelopeId = item.Request.EnvelopeId,
|
||||
PolicyVersion = item.Request.PolicyVersion,
|
||||
RefreshProof = item.Request.RefreshProof
|
||||
},
|
||||
Result = item.Result
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRequestValid(BulkVerificationItemRequest request)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.Uuid))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.ArtifactSha256))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.IsNullOrEmpty(request.Subject) && !string.IsNullOrEmpty(request.EnvelopeId);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
internal sealed record BulkVerificationJobDto
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string CreatedAt { get; init; }
|
||||
public string? StartedAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
public int Total { get; init; }
|
||||
public int Processed { get; init; }
|
||||
public int Succeeded { get; init; }
|
||||
public int Failed { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
public required BulkVerificationJobItemDto[] Items { get; init; }
|
||||
public required BulkVerificationJobContextDto Context { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BulkVerificationJobContextDto
|
||||
{
|
||||
public string? Tenant { get; init; }
|
||||
public string? RequestedBy { get; init; }
|
||||
public string? ClientId { get; init; }
|
||||
public string[] Scopes { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed record BulkVerificationJobItemDto
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? StartedAt { get; init; }
|
||||
public string? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public required BulkVerificationItemRequestDto Request { get; init; }
|
||||
public AttestorVerificationResult? Result { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record BulkVerificationItemRequestDto
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
public string? ArtifactSha256 { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? EnvelopeId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public bool RefreshProof { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BulkVerificationRequestDto
|
||||
{
|
||||
public BulkVerificationRequestItemDto[]? Items { get; init; }
|
||||
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
public bool? RefreshProof { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class BulkVerificationRequestItemDto
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
public string? ArtifactSha256 { get; init; }
|
||||
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? EnvelopeId { get; init; }
|
||||
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
public bool? RefreshProof { get; init; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
|
||||
@@ -9,15 +9,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastr
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.WebService", "StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj", "{B238B098-32B1-4875-99A7-393A63AC3CCF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\StellaOps.Configuration\StellaOps.Configuration.csproj", "{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{82EFA477-307D-4B47-A4CF-1627F076D60A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{21327A4F-2586-49F8-9D4A-3840DE64C48E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}"
|
||||
EndProject
|
||||
Global
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{82EFA477-307D-4B47-A4CF-1627F076D60A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{21327A4F-2586-49F8-9D4A-3840DE64C48E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Tests", "StellaOps.Attestor.Tests\StellaOps.Attestor.Tests.csproj", "{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Verify", "..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj", "{99EC90D8-0D5E-41E4-A895-585A7680916C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
@@ -108,10 +110,22 @@ Global
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Release|x86.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{99EC90D8-0D5E-41E4-A895-585A7680916C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
|
||||
@@ -13,26 +13,29 @@
|
||||
### Sprint 72 – Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-72-001 | TODO | Attestor Service Guild | ATTEST-ENVELOPE-72-001 | Scaffold service (REST API skeleton, storage interfaces, KMS integration stubs) and DSSE validation pipeline. | Service builds/tests; signing & verification stubs wired; lint/CI green. |
|
||||
| ATTESTOR-72-002 | TODO | Attestor Service Guild | ATTESTOR-72-001 | Implement attestation store (DB tables, object storage integration), CRUD, and indexing strategies. | Migrations applied; CRUD API functional; storage integration unit tests pass. |
|
||||
| ATTESTOR-72-001 | DONE | Attestor Service Guild | ATTEST-ENVELOPE-72-001 | Scaffold service (REST API skeleton, storage interfaces, KMS integration stubs) and DSSE validation pipeline. | Service builds/tests; signing & verification stubs wired; lint/CI green. |
|
||||
| ATTESTOR-72-002 | DONE | Attestor Service Guild | ATTESTOR-72-001 | Implement attestation store (DB tables, object storage integration), CRUD, and indexing strategies. | Migrations applied; CRUD API functional; storage integration unit tests pass. |
|
||||
| ATTESTOR-72-003 | BLOCKED | Attestor Service Guild, QA Guild | ATTESTOR-72-002 | Validate attestation store TTL against production-like Mongo/Redis stack; capture logs and remediation plan. | Evidence of TTL expiry captured; report archived in docs/modules/attestor/ttl-validation.md. |
|
||||
|
||||
### Sprint 73 – Signing & Verification
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-73-001 | TODO | Attestor Service Guild, KMS Guild | ATTESTOR-72-002, KMS-72-001 | Implement signing endpoint with Ed25519/ECDSA support, KMS integration, and audit logging. | `POST /v1/attestations:sign` functional; audit entries recorded; tests cover success/failure. |
|
||||
| ATTESTOR-73-002 | TODO | Attestor Service Guild, Policy Guild | ATTESTOR-72-002, VERPOL-73-001 | Build verification pipeline evaluating DSSE signatures, issuer trust, and verification policies; persist reports. | Verification endpoint returns structured report; results cached; contract tests pass. |
|
||||
| ATTESTOR-73-003 | TODO | Attestor Service Guild | ATTESTOR-73-002 | Implement listing/fetch APIs with filters (subject, type, issuer, scope, date). | API documented; pagination works; contract tests green. |
|
||||
| ATTESTOR-73-001 | DONE (2025-11-01) | Attestor Service Guild, KMS Guild | ATTESTOR-72-002, KMS-72-001 | Implement signing endpoint with Ed25519/ECDSA support, KMS integration, and audit logging. | `POST /v1/attestations:sign` functional; audit entries recorded; tests cover success/failure. |
|
||||
| ATTESTOR-73-002 | DONE (2025-11-01) | Attestor Service Guild, Policy Guild | ATTESTOR-72-002, VERPOL-73-001 | Build verification pipeline evaluating DSSE signatures, issuer trust, and verification policies; persist reports. | Verification endpoint returns structured report; results cached; contract tests pass. |
|
||||
| ATTESTOR-73-003 | DONE | Attestor Service Guild | ATTESTOR-73-002 | Implement listing/fetch APIs with filters (subject, type, issuer, scope, date). | API documented; pagination works; contract tests green. |
|
||||
|
||||
> 2025-11-01: Verification endpoints now return structured reports and persist cached results; telemetry and tests (AttestorVerificationServiceTests, CachedAttestorVerificationServiceTests) cover pass/fail/cached paths.
|
||||
|
||||
### Sprint 74 – Transparency & Bulk
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-74-001 | TODO | Attestor Service Guild | ATTESTOR-73-002, TRANSP-74-001 | Integrate transparency witness client, inclusion proof verification, and caching. | Witness proofs stored; verification fails on missing/inconsistent proofs; metrics emitted. |
|
||||
| ATTESTOR-74-002 | TODO | Attestor Service Guild | ATTESTOR-73-002 | Implement bulk verification worker + API with progress tracking, rate limits, and caching. | Bulk job API functional; worker processes batches; telemetry recorded. |
|
||||
| ATTESTOR-74-001 | DONE (2025-11-02) | Attestor Service Guild | ATTESTOR-73-002, TRANSP-74-001 | Integrate transparency witness client, inclusion proof verification, and caching. | Witness proofs stored; verification fails on missing/inconsistent proofs; metrics emitted. |
|
||||
| ATTESTOR-74-002 | DONE | Attestor Service Guild | ATTESTOR-73-002 | Implement bulk verification worker + API with progress tracking, rate limits, and caching. | Bulk job API functional; worker processes batches; telemetry recorded. |
|
||||
|
||||
### Sprint 75 – Air Gap & Hardening
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-75-001 | TODO | Attestor Service Guild, Export Guild | ATTESTOR-74-002, EXPORT-ATTEST-74-001 | Add export/import flows for attestation bundles and offline verification mode. | Bundles generated/imported; offline verification path documented; tests cover missing witness data. |
|
||||
| ATTESTOR-75-002 | TODO | Attestor Service Guild, Security Guild | ATTESTOR-73-002 | Harden APIs with rate limits, auth scopes, threat model mitigations, and fuzz testing. | Rate limiting enforced; fuzz tests run in CI; threat model actions resolved. |
|
||||
| ATTESTOR-75-001 | DONE | Attestor Service Guild, Export Guild | ATTESTOR-74-002, EXPORT-ATTEST-74-001 | Add export/import flows for attestation bundles and offline verification mode. | Bundles generated/imported; offline verification path documented; tests cover missing witness data. |
|
||||
| ATTESTOR-75-002 | DONE | Attestor Service Guild, Security Guild | ATTESTOR-73-002 | Harden APIs with rate limits, auth scopes, threat model mitigations, and fuzz testing. | Rate limiting enforced; fuzz tests run in CI; threat model actions resolved. |
|
||||
|
||||
*** End Task Board ***
|
||||
|
||||
Reference in New Issue
Block a user