Add Authority Advisory AI and API Lifecycle Configuration

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

View File

@@ -0,0 +1,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
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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>();
}

View File

@@ -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);
}

View File

@@ -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>();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,9 @@
using System;
namespace StellaOps.Attestor.Core.Transparency;
public sealed record TransparencyWitnessRequest(
string Uuid,
string Backend,
Uri BackendUrl,
string? CheckpointRootHash);

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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');
}

View File

@@ -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();
}
}

View File

@@ -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
};
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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>()
}
});
}
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]

View File

@@ -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

View File

@@ -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 ***