Resolve Concelier/Excititor merge conflicts
This commit is contained in:
21
src/StellaOps.Attestor/AGENTS.md
Normal file
21
src/StellaOps.Attestor/AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Attestor Guild
|
||||
|
||||
## Mission
|
||||
Operate the StellaOps Attestor service: accept signed DSSE envelopes from the Signer over mTLS, submit them to Rekor v2, persist inclusion proofs, and expose verification APIs for downstream services and operators.
|
||||
|
||||
## Teams On Call
|
||||
- Team 11 (Attestor API)
|
||||
- Team 12 (Attestor Observability) — partners on logging, metrics, and alerting
|
||||
|
||||
## Operating Principles
|
||||
- Enforce mTLS + Authority tokens for every submission; never accept anonymous callers.
|
||||
- Deterministic hashing, canonical JSON, and idempotent Rekor interactions (`bundleSha256` is the source of truth).
|
||||
- Persist everything (entries, dedupe, audit) before acknowledging; background jobs must be resumable.
|
||||
- Structured logs + metrics for each stage (`validate`, `submit`, `proof`, `persist`, `archive`).
|
||||
- Update `TASKS.md`, architecture docs, and tests whenever behaviour changes.
|
||||
|
||||
## Key Directories
|
||||
- `src/StellaOps.Attestor/StellaOps.Attestor.WebService/` — Minimal API host and HTTP surface.
|
||||
- `src/StellaOps.Attestor/StellaOps.Attestor.Core/` — Domain contracts, submission/verification pipelines.
|
||||
- `src/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/` — Mongo, Redis, Rekor, and archival implementations.
|
||||
- `src/StellaOps.Attestor/StellaOps.Attestor.Tests/` — Unit and integration tests.
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Audit;
|
||||
|
||||
public sealed class AttestorAuditRecord
|
||||
{
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
public string Result { get; init; } = string.Empty;
|
||||
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
public long? Index { get; init; }
|
||||
|
||||
public string ArtifactSha256 { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public string Backend { get; init; } = string.Empty;
|
||||
|
||||
public long LatencyMs { get; init; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public CallerDescriptor Caller { get; init; } = new();
|
||||
|
||||
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public sealed class CallerDescriptor
|
||||
{
|
||||
public string? Subject { get; init; }
|
||||
|
||||
public string? Audience { get; init; }
|
||||
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
public string? MtlsThumbprint { get; init; }
|
||||
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
public interface IRekorClient
|
||||
{
|
||||
Task<RekorSubmissionResponse> SubmitAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RekorProofResponse?> GetProofAsync(
|
||||
string rekorUuid,
|
||||
RekorBackend backend,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
public sealed class RekorBackend
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required Uri Url { get; init; }
|
||||
|
||||
public TimeSpan ProofTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
public int MaxAttempts { get; init; } = 60;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
public sealed class RekorProofResponse
|
||||
{
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public RekorCheckpoint? Checkpoint { get; set; }
|
||||
|
||||
[JsonPropertyName("inclusion")]
|
||||
public RekorInclusionProof? Inclusion { get; set; }
|
||||
|
||||
public sealed class RekorCheckpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string? Origin { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; set; }
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; set; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RekorInclusionProof
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string? LeafHash { get; set; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string> Path { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
public sealed class RekorSubmissionResponse
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string Uuid { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public string? LogUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "included";
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public RekorProofResponse? Proof { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
public sealed class AttestorArchiveBundle
|
||||
{
|
||||
public string RekorUuid { get; init; } = string.Empty;
|
||||
|
||||
public string ArtifactSha256 { get; init; } = string.Empty;
|
||||
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
public byte[] CanonicalBundleJson { get; init; } = Array.Empty<byte>();
|
||||
|
||||
public byte[] ProofJson { get; init; } = Array.Empty<byte>();
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
public interface IAttestorArchiveStore
|
||||
{
|
||||
Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
public interface IAttestorAuditSink
|
||||
{
|
||||
Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
public interface IAttestorDedupeStore
|
||||
{
|
||||
Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Storage;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
/// <summary>
|
||||
/// Incoming submission payload for <c>/api/v1/rekor/entries</c>.
|
||||
/// </summary>
|
||||
public sealed class AttestorSubmissionRequest
|
||||
{
|
||||
[JsonPropertyName("bundle")]
|
||||
public SubmissionBundle Bundle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("meta")]
|
||||
public SubmissionMeta Meta { get; set; } = new();
|
||||
|
||||
public sealed class SubmissionBundle
|
||||
{
|
||||
[JsonPropertyName("dsse")]
|
||||
public DsseEnvelope Dsse { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("certificateChain")]
|
||||
public IList<string> CertificateChain { get; set; } = new List<string>();
|
||||
|
||||
[JsonPropertyName("mode")]
|
||||
public string Mode { get; set; } = "keyless";
|
||||
}
|
||||
|
||||
public sealed class DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string PayloadBase64 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IList<DsseSignature> Signatures { get; set; } = new List<DsseSignature>();
|
||||
}
|
||||
|
||||
public sealed class DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SubmissionMeta
|
||||
{
|
||||
[JsonPropertyName("artifact")]
|
||||
public ArtifactInfo Artifact { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("bundleSha256")]
|
||||
public string BundleSha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logPreference")]
|
||||
public string LogPreference { get; set; } = "primary";
|
||||
|
||||
[JsonPropertyName("archive")]
|
||||
public bool Archive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ArtifactInfo
|
||||
{
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; set; }
|
||||
|
||||
[JsonPropertyName("subjectUri")]
|
||||
public string? SubjectUri { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
public sealed class AttestorSubmissionValidationResult
|
||||
{
|
||||
public AttestorSubmissionValidationResult(byte[] canonicalBundle)
|
||||
{
|
||||
CanonicalBundle = canonicalBundle;
|
||||
}
|
||||
|
||||
public byte[] CanonicalBundle { get; }
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Buffers.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
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);
|
||||
|
||||
if (request.Bundle is null)
|
||||
{
|
||||
throw new AttestorValidationException("bundle_missing", "Submission bundle payload is required.");
|
||||
}
|
||||
|
||||
if (request.Bundle.Dsse is null)
|
||||
{
|
||||
throw new AttestorValidationException("dsse_missing", "DSSE envelope is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadType))
|
||||
{
|
||||
throw new AttestorValidationException("payload_type_missing", "DSSE payloadType is required.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
if (request.Meta is null)
|
||||
{
|
||||
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
|
||||
}
|
||||
|
||||
if (request.Meta.Artifact is null)
|
||||
{
|
||||
throw new AttestorValidationException("artifact_missing", "Artifact metadata is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Meta.Artifact.Sha256))
|
||||
{
|
||||
throw new AttestorValidationException("artifact_sha_missing", "Artifact sha256 is required.");
|
||||
}
|
||||
|
||||
if (!IsHex(request.Meta.Artifact.Sha256, expectedLength: 64))
|
||||
{
|
||||
throw new AttestorValidationException("artifact_sha_invalid", "Artifact sha256 must be a 64-character hex string.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
|
||||
{
|
||||
throw new AttestorValidationException("bundle_sha_missing", "bundleSha256 is required.");
|
||||
}
|
||||
|
||||
if (!IsHex(request.Meta.BundleSha256, expectedLength: 64))
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
if (!string.Equals(hashHex, request.Meta.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AttestorValidationException("bundle_sha_mismatch", "bundleSha256 does not match canonical DSSE hash.");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Meta.LogPreference, "primary", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(request.Meta.LogPreference, "mirror", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', or 'both'.");
|
||||
}
|
||||
|
||||
return new AttestorSubmissionValidationResult(canonical);
|
||||
}
|
||||
|
||||
private static bool IsHex(string value, int expectedLength)
|
||||
{
|
||||
if (value.Length != expectedLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool Base64UrlDecode(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(Normalise(value));
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalise(string value)
|
||||
{
|
||||
if (value.Contains('-') || value.Contains('_'))
|
||||
{
|
||||
Span<char> buffer = value.ToCharArray();
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
buffer[i] = buffer[i] switch
|
||||
{
|
||||
'-' => '+',
|
||||
'_' => '/',
|
||||
_ => buffer[i]
|
||||
};
|
||||
}
|
||||
|
||||
var padding = 4 - (buffer.Length % 4);
|
||||
return padding == 4 ? new string(buffer) : new string(buffer) + new string('=', padding);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
public sealed class AttestorValidationException : Exception
|
||||
{
|
||||
public AttestorValidationException(string code, string message)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
public interface IAttestorSubmissionService
|
||||
{
|
||||
Task<AttestorSubmissionResult> SubmitAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
public interface IDsseCanonicalizer
|
||||
{
|
||||
Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Submission;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient information about the caller used for policy and audit decisions.
|
||||
/// </summary>
|
||||
public sealed class SubmissionContext
|
||||
{
|
||||
public required string CallerSubject { get; init; }
|
||||
|
||||
public required string CallerAudience { get; init; }
|
||||
|
||||
public required string? CallerClientId { get; init; }
|
||||
|
||||
public required string? CallerTenant { get; init; }
|
||||
|
||||
public X509Certificate2? ClientCertificate { get; init; }
|
||||
|
||||
public string? MtlsThumbprint { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
public sealed class AttestorVerificationException : Exception
|
||||
{
|
||||
public AttestorVerificationException(string code, string message)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Payload accepted by the verification service.
|
||||
/// </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; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
public sealed class AttestorVerificationResult
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Verification;
|
||||
|
||||
public interface IAttestorVerificationService
|
||||
{
|
||||
Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
|
||||
internal sealed class HttpRekorClient : IRekorClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpRekorClient> _logger;
|
||||
|
||||
public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
|
||||
{
|
||||
Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Rekor reported a conflict: {message}");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = document.RootElement;
|
||||
|
||||
long? index = null;
|
||||
if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
|
||||
{
|
||||
index = indexValue;
|
||||
}
|
||||
|
||||
return new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
|
||||
Index = index,
|
||||
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
|
||||
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
|
||||
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Rekor proof for {Uuid} not found", rekorUuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return TryParseProof(document.RootElement);
|
||||
}
|
||||
|
||||
private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
|
||||
{
|
||||
var signatures = new List<object>();
|
||||
foreach (var sig in request.Bundle.Dsse.Signatures)
|
||||
{
|
||||
signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
dsseEnvelope = new
|
||||
{
|
||||
payload = request.Bundle.Dsse.PayloadBase64,
|
||||
payloadType = request.Bundle.Dsse.PayloadType,
|
||||
signatures
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RekorProofResponse? TryParseProof(JsonElement proofElement)
|
||||
{
|
||||
if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
|
||||
var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
|
||||
|
||||
return new RekorProofResponse
|
||||
{
|
||||
Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
|
||||
? new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
|
||||
Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
|
||||
RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
|
||||
Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
|
||||
}
|
||||
: null,
|
||||
Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
|
||||
? new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
|
||||
Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
|
||||
? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
|
||||
: Array.Empty<string>()
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri BuildUri(Uri baseUri, string relative)
|
||||
{
|
||||
if (!relative.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
relative = "/" + relative;
|
||||
}
|
||||
|
||||
return new Uri(baseUri, relative);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
|
||||
internal sealed class StubRekorClient : IRekorClient
|
||||
{
|
||||
private readonly ILogger<StubRekorClient> _logger;
|
||||
|
||||
public StubRekorClient(ILogger<StubRekorClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var uuid = Guid.NewGuid().ToString();
|
||||
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
|
||||
|
||||
var proof = new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = request.Meta.BundleSha256,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = request.Meta.BundleSha256,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
};
|
||||
|
||||
var response = new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = uuid,
|
||||
Index = Random.Shared.NextInt64(1, long.MaxValue),
|
||||
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
|
||||
Status = "included",
|
||||
Proof = proof
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
|
||||
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = string.Empty,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
|
||||
});
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
services.AddHttpClient<HttpRekorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
|
||||
|
||||
services.AddSingleton<IMongoClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Mongo.Uri))
|
||||
{
|
||||
throw new InvalidOperationException("Attestor MongoDB connection string is not configured.");
|
||||
}
|
||||
|
||||
return new MongoClient(options.Mongo.Uri);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
var databaseName = MongoUrl.Create(opts.Mongo.Uri).DatabaseName ?? opts.Mongo.Database;
|
||||
return client.GetDatabase(databaseName);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<MongoAttestorEntryRepository.AttestorEntryDocument>(opts.Mongo.EntriesCollection);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<MongoAttestorAuditSink.AttestorAuditDocument>(opts.Mongo.AuditCollection);
|
||||
});
|
||||
|
||||
services.AddSingleton<IAttestorEntryRepository, MongoAttestorEntryRepository>();
|
||||
services.AddSingleton<IAttestorAuditSink, MongoAttestorAuditSink>();
|
||||
|
||||
|
||||
services.AddSingleton<IAttestorDedupeStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Redis.Url))
|
||||
{
|
||||
return new InMemoryAttestorDedupeStore();
|
||||
}
|
||||
|
||||
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
return new RedisAttestorDedupeStore(multiplexer, sp.GetRequiredService<IOptions<AttestorOptions>>());
|
||||
});
|
||||
|
||||
services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Redis.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Redis connection string is required when redis dedupe is enabled.");
|
||||
}
|
||||
|
||||
return ConnectionMultiplexer.Connect(options.Redis.Url);
|
||||
});
|
||||
|
||||
services.AddSingleton<IAttestorArchiveStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (options.S3.Enabled && !string.IsNullOrWhiteSpace(options.S3.Endpoint) && !string.IsNullOrWhiteSpace(options.S3.Bucket))
|
||||
{
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = options.S3.Endpoint,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = !options.S3.UseTls
|
||||
};
|
||||
|
||||
var client = new AmazonS3Client(FallbackCredentialsFactory.GetCredentials(), config);
|
||||
return new S3AttestorArchiveStore(client, sp.GetRequiredService<IOptions<AttestorOptions>>(), sp.GetRequiredService<ILogger<S3AttestorArchiveStore>>());
|
||||
}
|
||||
|
||||
return new NullAttestorArchiveStore(sp.GetRequiredService<ILogger<NullAttestorArchiveStore>>());
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<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="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
|
||||
|
||||
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.TryGetValue(bundleSha256, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Task.FromResult<string?>(entry.Uuid);
|
||||
}
|
||||
|
||||
_store.TryRemove(bundleSha256, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
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 AttestorAuditDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
|
||||
[BsonElement("ts")]
|
||||
public BsonDateTime Timestamp { get; set; } = BsonDateTime.Create(DateTime.UtcNow);
|
||||
|
||||
[BsonElement("action")]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("result")]
|
||||
public string Result { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("rekorUuid")]
|
||||
public string? RekorUuid { get; set; }
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("artifactSha256")]
|
||||
public string ArtifactSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("bundleSha256")]
|
||||
public string BundleSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("latencyMs")]
|
||||
public long LatencyMs { get; set; }
|
||||
|
||||
[BsonElement("caller")]
|
||||
public CallerDocument Caller { get; set; } = new();
|
||||
|
||||
[BsonElement("metadata")]
|
||||
public BsonDocument Metadata { get; set; } = new();
|
||||
|
||||
public static AttestorAuditDocument FromRecord(AttestorAuditRecord record)
|
||||
{
|
||||
var metadata = new BsonDocument();
|
||||
foreach (var kvp in record.Metadata)
|
||||
{
|
||||
metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return new AttestorAuditDocument
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
Timestamp = BsonDateTime.Create(record.Timestamp.UtcDateTime),
|
||||
Action = record.Action,
|
||||
Result = record.Result,
|
||||
RekorUuid = record.RekorUuid,
|
||||
Index = record.Index,
|
||||
ArtifactSha256 = record.ArtifactSha256,
|
||||
BundleSha256 = record.BundleSha256,
|
||||
Backend = record.Backend,
|
||||
LatencyMs = record.LatencyMs,
|
||||
Caller = new CallerDocument
|
||||
{
|
||||
Subject = record.Caller.Subject,
|
||||
Audience = record.Caller.Audience,
|
||||
ClientId = record.Caller.ClientId,
|
||||
MtlsThumbprint = record.Caller.MtlsThumbprint,
|
||||
Tenant = record.Caller.Tenant
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class CallerDocument
|
||||
{
|
||||
[BsonElement("subject")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[BsonElement("audience")]
|
||||
public string? Audience { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("mtlsThumbprint")]
|
||||
public string? MtlsThumbprint { get; set; }
|
||||
|
||||
[BsonElement("tenant")]
|
||||
public string? Tenant { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
|
||||
{
|
||||
private readonly ILogger<NullAttestorArchiveStore> _logger;
|
||||
|
||||
public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
|
||||
{
|
||||
private readonly IDatabase _database;
|
||||
private readonly string _prefix;
|
||||
|
||||
public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
|
||||
{
|
||||
_database = multiplexer.GetDatabase();
|
||||
_prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
|
||||
}
|
||||
|
||||
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
|
||||
return value.HasValue ? value.ToString() : null;
|
||||
}
|
||||
|
||||
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
|
||||
}
|
||||
|
||||
private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Submission;
|
||||
|
||||
internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
{
|
||||
private static readonly TimeSpan DedupeTtl = TimeSpan.FromHours(48);
|
||||
|
||||
private readonly AttestorSubmissionValidator _validator;
|
||||
private readonly IAttestorEntryRepository _repository;
|
||||
private readonly IAttestorDedupeStore _dedupeStore;
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly IAttestorArchiveStore _archiveStore;
|
||||
private readonly IAttestorAuditSink _auditSink;
|
||||
private readonly ILogger<AttestorSubmissionService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorSubmissionService(
|
||||
AttestorSubmissionValidator validator,
|
||||
IAttestorEntryRepository repository,
|
||||
IAttestorDedupeStore dedupeStore,
|
||||
IRekorClient rekorClient,
|
||||
IAttestorArchiveStore archiveStore,
|
||||
IAttestorAuditSink auditSink,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorSubmissionService> logger,
|
||||
TimeProvider timeProvider,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_validator = validator;
|
||||
_repository = repository;
|
||||
_dedupeStore = dedupeStore;
|
||||
_rekorClient = rekorClient;
|
||||
_archiveStore = archiveStore;
|
||||
_auditSink = auditSink;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorSubmissionResult> SubmitAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var canonicalBundle = validation.CanonicalBundle;
|
||||
|
||||
var preference = NormalizeLogPreference(request.Meta.LogPreference);
|
||||
var requiresPrimary = preference is "primary" or "both";
|
||||
var requiresMirror = preference is "mirror" or "both";
|
||||
|
||||
if (!requiresPrimary && !requiresMirror)
|
||||
{
|
||||
requiresPrimary = true;
|
||||
}
|
||||
|
||||
if (requiresMirror && !_options.Rekor.Mirror.Enabled)
|
||||
{
|
||||
throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
|
||||
}
|
||||
|
||||
var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
|
||||
var updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
|
||||
return ToResult(updated);
|
||||
}
|
||||
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
|
||||
SubmissionOutcome? canonicalOutcome = null;
|
||||
SubmissionOutcome? mirrorOutcome = null;
|
||||
|
||||
if (requiresPrimary)
|
||||
{
|
||||
canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (requiresMirror)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
canonicalOutcome = mirror;
|
||||
}
|
||||
else
|
||||
{
|
||||
mirrorOutcome = mirror;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(mirrorOutcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw new InvalidOperationException("No Rekor submission outcome was produced.");
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.Meta.Archive)
|
||||
{
|
||||
await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
|
||||
if (mirrorOutcome is not null)
|
||||
{
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ToResult(entry);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
|
||||
{
|
||||
var result = new AttestorSubmissionResult
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Proof = ToResultProof(entry.Proof)
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
result.Mirror = new AttestorSubmissionResult.MirrorLog
|
||||
{
|
||||
Uuid = entry.Mirror.Uuid,
|
||||
Index = entry.Mirror.Index,
|
||||
LogUrl = entry.Mirror.Url,
|
||||
Status = entry.Mirror.Status,
|
||||
Proof = ToResultProof(entry.Mirror.Proof),
|
||||
Error = entry.Mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private AttestorEntry CreateEntry(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
SubmissionOutcome canonicalOutcome,
|
||||
SubmissionOutcome? mirrorOutcome)
|
||||
{
|
||||
if (canonicalOutcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
|
||||
}
|
||||
|
||||
var submission = canonicalOutcome.Submission;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = submission.Uuid,
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = request.Meta.Artifact.Sha256,
|
||||
Kind = request.Meta.Artifact.Kind,
|
||||
ImageDigest = request.Meta.Artifact.ImageDigest,
|
||||
SubjectUri = request.Meta.Artifact.SubjectUri
|
||||
},
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Index = submission.Index,
|
||||
Proof = ConvertProof(canonicalOutcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = canonicalOutcome.Backend,
|
||||
Url = submission.LogUrl ?? canonicalOutcome.Url,
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = now,
|
||||
Status = submission.Status ?? "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = request.Bundle.Mode,
|
||||
Issuer = context.CallerAudience,
|
||||
SubjectAlternativeName = context.CallerSubject,
|
||||
KeyId = context.CallerClientId
|
||||
},
|
||||
Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeLogPreference(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "primary";
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"primary" => "primary",
|
||||
"mirror" => "mirror",
|
||||
"both" => "both",
|
||||
_ => "primary"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
|
||||
{
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(dedupeUuid))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry> EnsureBackendsAsync(
|
||||
AttestorEntry existing,
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
bool requiresPrimary,
|
||||
bool requiresMirror,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = existing;
|
||||
var updated = false;
|
||||
|
||||
if (requiresPrimary && !IsPrimary(entry))
|
||||
{
|
||||
var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
entry = PromoteToPrimary(entry, outcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (requiresMirror)
|
||||
{
|
||||
var mirrorSatisfied = entry.Mirror is not null
|
||||
&& entry.Mirror.Error is null
|
||||
&& string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(entry.Mirror.Uuid);
|
||||
|
||||
if (!mirrorSatisfied)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
entry = WithMirror(entry, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(failure);
|
||||
entry = WithMirror(entry, failure);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsPrimary(AttestorEntry entry) =>
|
||||
string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<SubmissionOutcome> SubmitToBackendAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
string backendName,
|
||||
AttestorOptions.RekorBackendOptions backendOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = BuildBackend(backendName, backendOptions);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var proof = submission.Proof;
|
||||
if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submission.Uuid, backendName);
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
|
||||
RecordSubmissionMetrics(outcome);
|
||||
return outcome;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSubmissionMetrics(SubmissionOutcome outcome)
|
||||
{
|
||||
var result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "unknown"
|
||||
: "failed";
|
||||
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", result),
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
|
||||
if (outcome.Latency > TimeSpan.Zero)
|
||||
{
|
||||
_metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ArchiveAsync(
|
||||
AttestorEntry entry,
|
||||
byte[] canonicalBundle,
|
||||
RekorProofResponse? proof,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
metadata["mirror.backend"] = entry.Mirror.Backend;
|
||||
metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
|
||||
metadata["mirror.status"] = entry.Mirror.Status;
|
||||
}
|
||||
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAuditAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
AttestorEntry entry,
|
||||
SubmissionOutcome outcome,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (!outcome.IsSuccess && outcome.Error is not null)
|
||||
{
|
||||
metadata["error"] = outcome.Error.Message;
|
||||
}
|
||||
|
||||
var record = new AttestorAuditRecord
|
||||
{
|
||||
Action = "submit",
|
||||
Result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
RekorUuid = outcome.IsSuccess
|
||||
? outcome.Submission!.Uuid
|
||||
: string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
|
||||
? entry.RekorUuid
|
||||
: entry.Mirror?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
ArtifactSha256 = request.Meta.Artifact.Sha256,
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Backend = outcome.Backend,
|
||||
LatencyMs = (long)outcome.Latency.TotalMilliseconds,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Caller = new AttestorAuditRecord.CallerDescriptor
|
||||
{
|
||||
Subject = context.CallerSubject,
|
||||
Audience = context.CallerAudience,
|
||||
ClientId = context.CallerClientId,
|
||||
MtlsThumbprint = context.MtlsThumbprint,
|
||||
Tenant = context.CallerTenant
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
return _auditSink.WriteAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.IsSuccess
|
||||
? outcome.Submission!.LogUrl ?? outcome.Url
|
||||
: outcome.Url,
|
||||
Uuid = outcome.Submission?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
Status = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
|
||||
Error = outcome.Error?.Message
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = entry.Proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity,
|
||||
Mirror = CreateMirrorDescriptor(outcome)
|
||||
};
|
||||
}
|
||||
|
||||
private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
|
||||
{
|
||||
if (outcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
|
||||
}
|
||||
|
||||
var mirrorDescriptor = existing.Mirror;
|
||||
if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
|
||||
}
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = outcome.Submission.Uuid,
|
||||
Artifact = existing.Artifact,
|
||||
BundleSha256 = existing.BundleSha256,
|
||||
Index = outcome.Submission.Index,
|
||||
Proof = ConvertProof(outcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.Submission.LogUrl ?? outcome.Url,
|
||||
LogId = existing.Log.LogId
|
||||
},
|
||||
CreatedAt = existing.CreatedAt,
|
||||
Status = outcome.Submission.Status ?? "included",
|
||||
SignerIdentity = existing.SignerIdentity,
|
||||
Mirror = mirrorDescriptor
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof,
|
||||
LogId = entry.Log.LogId
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SubmissionOutcome(
|
||||
string Backend,
|
||||
string Url,
|
||||
RekorSubmissionResponse? Submission,
|
||||
RekorProofResponse? Proof,
|
||||
TimeSpan Latency,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => Submission is not null && Error is null;
|
||||
|
||||
public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
|
||||
|
||||
public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
{
|
||||
throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
|
||||
}
|
||||
|
||||
return new RekorBackend
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(options.Url, UriKind.Absolute),
|
||||
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
|
||||
MaxAttempts = options.MaxAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Submission;
|
||||
|
||||
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["payloadType"] = request.Bundle.Dsse.PayloadType,
|
||||
["payload"] = request.Bundle.Dsse.PayloadBase64,
|
||||
["signatures"] = CreateSignaturesArray(request)
|
||||
};
|
||||
|
||||
var json = node.ToJsonString(SerializerOptions);
|
||||
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
|
||||
}
|
||||
|
||||
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var signature in request.Bundle.Dsse.Signatures)
|
||||
{
|
||||
var obj = new JsonObject
|
||||
{
|
||||
["sig"] = signature.Signature
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(signature.KeyId))
|
||||
{
|
||||
obj["keyid"] = signature.KeyId;
|
||||
}
|
||||
|
||||
array.Add(obj);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
{
|
||||
private readonly IAttestorEntryRepository _repository;
|
||||
private readonly IDsseCanonicalizer _canonicalizer;
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly ILogger<AttestorVerificationService> _logger;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorVerificationService(
|
||||
IAttestorEntryRepository repository,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationService> logger,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_canonicalizer = canonicalizer;
|
||||
_rekorClient = rekorClient;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var entry = await ResolveEntryAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
throw new AttestorVerificationException("not_found", "No attestor entry matched the supplied query.");
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (request.Bundle is not null)
|
||||
{
|
||||
var canonicalBundle = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = request.Bundle,
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind
|
||||
},
|
||||
BundleSha256 = entry.BundleSha256
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("bundle_hash_mismatch");
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
issues.Add("bundle_payload_invalid_base64");
|
||||
}
|
||||
else
|
||||
{
|
||||
var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
|
||||
VerifySignatures(entry, request.Bundle, preAuth, issues);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
|
||||
}
|
||||
|
||||
if (request.RefreshProof || entry.Proof is null)
|
||||
{
|
||||
var backend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
try
|
||||
{
|
||||
var proof = await _rekorClient.GetProofAsync(entry.RekorUuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
if (proof is not null)
|
||||
{
|
||||
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
|
||||
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
entry = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", entry.RekorUuid);
|
||||
issues.Add("Proof refresh failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
VerifyMerkleProof(entry, issues);
|
||||
|
||||
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
|
||||
|
||||
return new AttestorVerificationResult
|
||||
{
|
||||
Ok = ok,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Issues = issues,
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rekorUuid))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", nameof(rekorUuid));
|
||||
}
|
||||
|
||||
return ResolveEntryByUuidAsync(rekorUuid, refreshProof, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryAsync(AttestorVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.Uuid))
|
||||
{
|
||||
return await ResolveEntryByUuidAsync(request.Uuid, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (request.Bundle is not null)
|
||||
{
|
||||
var canonical = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = request.Bundle,
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = string.Empty,
|
||||
Kind = string.Empty
|
||||
}
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return await ResolveEntryByBundleShaAsync(bundleSha, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactSha256))
|
||||
{
|
||||
return await ResolveEntryByArtifactAsync(request.ArtifactSha256, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new AttestorVerificationException("invalid_query", "At least one of uuid, bundle, or artifactSha256 must be provided.");
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryByUuidAsync(string uuid, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await _repository.GetByUuidAsync(uuid, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null || !refreshProof)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var backend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
try
|
||||
{
|
||||
var proof = await _rekorClient.GetProofAsync(uuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
if (proof is not null)
|
||||
{
|
||||
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
|
||||
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
entry = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", uuid);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryByBundleShaAsync(string bundleSha, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await _repository.GetByBundleShaAsync(bundleSha, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null || !refreshProof)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
return await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryByArtifactAsync(string artifactSha256, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await _repository.GetByArtifactShaAsync(artifactSha256, cancellationToken).ConfigureAwait(false);
|
||||
var entry = entries.OrderByDescending(e => e.CreatedAt).FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return refreshProof
|
||||
? await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false)
|
||||
: entry;
|
||||
}
|
||||
|
||||
private void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
if (mode == "kms")
|
||||
{
|
||||
if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
|
||||
{
|
||||
issues.Add("signature_invalid_kms");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == "keyless")
|
||||
{
|
||||
VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
|
||||
return;
|
||||
}
|
||||
|
||||
issues.Add(string.IsNullOrEmpty(mode)
|
||||
? "signer_mode_unknown"
|
||||
: $"signer_mode_unsupported:{mode}");
|
||||
}
|
||||
|
||||
private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
|
||||
{
|
||||
issues.Add("kms_key_missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
signatures.Add(signatureBytes);
|
||||
}
|
||||
|
||||
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
||||
{
|
||||
if (!TryDecodeSecret(secret, out var secretBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var computed = hmac.ComputeHash(preAuthEncoding);
|
||||
|
||||
foreach (var signatureBytes in signatures)
|
||||
{
|
||||
if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (bundle.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add("certificate_chain_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
foreach (var pem in bundle.CertificateChain)
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPem(pem));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
issues.Add("certificate_chain_invalid");
|
||||
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
var leafCertificate = certificates[0];
|
||||
|
||||
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(rootPath))
|
||||
{
|
||||
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chain.Build(leafCertificate))
|
||||
{
|
||||
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
|
||||
.Trim(';');
|
||||
issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
|
||||
{
|
||||
var sans = GetSubjectAlternativeNames(leafCertificate);
|
||||
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add("certificate_san_untrusted");
|
||||
}
|
||||
}
|
||||
|
||||
var signatureVerified = false;
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
|
||||
{
|
||||
signatureVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureVerified)
|
||||
{
|
||||
issues.Add("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
using (ecdsa)
|
||||
{
|
||||
return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
}
|
||||
|
||||
var rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
using (rsa)
|
||||
{
|
||||
return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var formatted = extension.Format(true);
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
|
||||
{
|
||||
if (entry.Proof is null)
|
||||
{
|
||||
issues.Add("proof_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
|
||||
{
|
||||
issues.Add("bundle_hash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion is null)
|
||||
{
|
||||
issues.Add("proof_inclusion_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion.LeafHash is not null)
|
||||
{
|
||||
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var current = bundleHash;
|
||||
|
||||
if (entry.Proof.Inclusion.Path.Count > 0)
|
||||
{
|
||||
var nodes = new List<ProofPathNode>();
|
||||
foreach (var element in entry.Proof.Inclusion.Path)
|
||||
{
|
||||
if (!ProofPathNode.TryParse(element, out var node))
|
||||
{
|
||||
issues.Add("proof_path_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.HasOrientation)
|
||||
{
|
||||
issues.Add("proof_path_orientation_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
current = node.Left
|
||||
? HashInternal(node.Hash, current)
|
||||
: HashInternal(current, node.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Proof.Checkpoint is null)
|
||||
{
|
||||
issues.Add("checkpoint_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
|
||||
{
|
||||
issues.Add("checkpoint_root_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
|
||||
{
|
||||
issues.Add("proof_root_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private static bool TryDecodeSecret(string value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeBase64(value[7..], out bytes);
|
||||
}
|
||||
|
||||
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeHex(value[4..], out bytes);
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeHex(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHex(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHash(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (TryDecodeHex(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly struct ProofPathNode
|
||||
{
|
||||
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
|
||||
{
|
||||
HasOrientation = hasOrientation;
|
||||
Left = left;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public bool HasOrientation { get; }
|
||||
|
||||
public bool Left { get; }
|
||||
|
||||
public byte[] Hash { get; }
|
||||
|
||||
public static bool TryParse(string value, out ProofPathNode node)
|
||||
{
|
||||
node = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(':', 2);
|
||||
bool hasOrientation = false;
|
||||
bool left = false;
|
||||
string hashPart = trimmed;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var prefix = parts[0].Trim().ToLowerInvariant();
|
||||
if (prefix is "l" or "left")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = true;
|
||||
}
|
||||
else if (prefix is "r" or "right")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = false;
|
||||
}
|
||||
|
||||
hashPart = parts[1].Trim();
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(hashPart, out var hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = new ProofPathNode(hasOrientation, left, hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity
|
||||
};
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
{
|
||||
throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
|
||||
}
|
||||
|
||||
return new RekorBackend
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(options.Url, UriKind.Absolute),
|
||||
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
|
||||
MaxAttempts = options.MaxAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RekorProofResponseExtensions
|
||||
{
|
||||
public static AttestorEntry.ProofDescriptor ToProofDescriptor(this RekorProofResponse response)
|
||||
{
|
||||
return new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = response.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = response.Checkpoint.Origin,
|
||||
Size = response.Checkpoint.Size,
|
||||
RootHash = response.Checkpoint.RootHash,
|
||||
Timestamp = response.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = response.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = response.Inclusion.LeafHash,
|
||||
Path = response.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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 Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorSubmissionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default",
|
||||
ClientCertificate = null,
|
||||
MtlsThumbprint = "00"
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validator_ThrowsWhenModeNotAllowed()
|
||||
{
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Bundle.Mode = "keyless";
|
||||
|
||||
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
|
||||
Assert.Equal("mirror_disabled", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
},
|
||||
Mirror = new AttestorOptions.RekorMirrorOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Url = "https://rekor.mirror.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "both";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var result = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(result.Mirror);
|
||||
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
|
||||
Assert.Equal("included", result.Mirror.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.primary.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
},
|
||||
Mirror = new AttestorOptions.RekorMirrorOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Url = "https://rekor.mirror.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var result = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(result.Uuid);
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("mirror", stored!.Log.Backend);
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "keyless",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures =
|
||||
{
|
||||
new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "test",
|
||||
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class HttpRekorClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitAsync_ParsesResponse()
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
uuid = "123",
|
||||
index = 42,
|
||||
logURL = "https://rekor.example/api/v2/log/entries/123",
|
||||
status = "included",
|
||||
proof = new
|
||||
{
|
||||
checkpoint = new { origin = "rekor", size = 10, rootHash = "abc", timestamp = "2025-10-19T00:00:00Z" },
|
||||
inclusion = new { leafHash = "leaf", path = new[] { "p1", "p2" } }
|
||||
}
|
||||
};
|
||||
|
||||
var client = CreateClient(HttpStatusCode.Created, payload);
|
||||
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
|
||||
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var backend = new RekorBackend
|
||||
{
|
||||
Name = "primary",
|
||||
Url = new Uri("https://rekor.example/"),
|
||||
ProofTimeout = TimeSpan.FromSeconds(1),
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
MaxAttempts = 1
|
||||
};
|
||||
|
||||
var response = await rekorClient.SubmitAsync(request, backend);
|
||||
|
||||
Assert.Equal("123", response.Uuid);
|
||||
Assert.Equal(42, response.Index);
|
||||
Assert.Equal("included", response.Status);
|
||||
Assert.NotNull(response.Proof);
|
||||
Assert.Equal("leaf", response.Proof!.Inclusion!.LeafHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_ThrowsOnConflict()
|
||||
{
|
||||
var client = CreateClient(HttpStatusCode.Conflict, new { error = "duplicate" });
|
||||
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
|
||||
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var backend = new RekorBackend
|
||||
{
|
||||
Name = "primary",
|
||||
Url = new Uri("https://rekor.example/"),
|
||||
ProofTimeout = TimeSpan.FromSeconds(1),
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
MaxAttempts = 1
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => rekorClient.SubmitAsync(request, backend));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetProofAsync_ReturnsNullOnNotFound()
|
||||
{
|
||||
var client = CreateClient(HttpStatusCode.NotFound, new { });
|
||||
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
|
||||
|
||||
var backend = new RekorBackend
|
||||
{
|
||||
Name = "primary",
|
||||
Url = new Uri("https://rekor.example/"),
|
||||
ProofTimeout = TimeSpan.FromSeconds(1),
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
MaxAttempts = 1
|
||||
};
|
||||
|
||||
var proof = await rekorClient.GetProofAsync("abc", backend);
|
||||
Assert.Null(proof);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(HttpStatusCode statusCode, object payload)
|
||||
{
|
||||
var handler = new StubHandler(statusCode, payload);
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://rekor.example/")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly object _payload;
|
||||
|
||||
public StubHandler(HttpStatusCode statusCode, object payload)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_payload = payload;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_payload);
|
||||
var response = new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
405
src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
Normal file
405
src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
Normal file
@@ -0,0 +1,405 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.RateLimiting;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using OpenTelemetry.Metrics;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
using Serilog.Context;
|
||||
|
||||
const string ConfigurationSection = "attestor";
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "ATTESTOR_";
|
||||
options.BindingSection = ConfigurationSection;
|
||||
});
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
|
||||
|
||||
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton(attestorOptions);
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = static (context, _) =>
|
||||
{
|
||||
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
options.AddPolicy("attestor-submissions", httpContext =>
|
||||
{
|
||||
var identity = httpContext.Connection.ClientCertificate?.Thumbprint
|
||||
?? httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.FindFirst("client_id")?.Value
|
||||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
?? "anonymous";
|
||||
|
||||
var quota = attestorOptions.Quotas.PerCaller;
|
||||
var tokensPerPeriod = Math.Max(1, quota.Qps);
|
||||
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
|
||||
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
|
||||
|
||||
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = tokenLimit,
|
||||
TokensPerPeriod = tokensPerPeriod,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
|
||||
QueueLimit = queueLimit,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<AttestorOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ConfigurationSection))
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy());
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metricsBuilder =>
|
||||
{
|
||||
metricsBuilder.AddMeter(AttestorMetrics.MeterName);
|
||||
metricsBuilder.AddAspNetCoreInstrumentation();
|
||||
metricsBuilder.AddRuntimeInstrumentation();
|
||||
});
|
||||
|
||||
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
|
||||
{
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authority.Issuer!;
|
||||
resourceOptions.RequireHttpsMetadata = authority.RequireHttpsMetadata;
|
||||
if (!string.IsNullOrWhiteSpace(authority.JwksUrl))
|
||||
{
|
||||
resourceOptions.MetadataAddress = authority.JwksUrl;
|
||||
}
|
||||
|
||||
foreach (var audience in authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authority.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("attestor:write", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireClaim("scope", authority.RequiredScopes);
|
||||
});
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthorization();
|
||||
}
|
||||
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
kestrel.ConfigureHttpsDefaults(https =>
|
||||
{
|
||||
if (attestorOptions.Security.Mtls.RequireClientCertificate)
|
||||
{
|
||||
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
|
||||
}
|
||||
|
||||
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
|
||||
|
||||
https.ClientCertificateValidation = (certificate, _, _) =>
|
||||
{
|
||||
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
Log.Warning("Client certificate missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clientCertificateAuthorities.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var authority in clientCertificateAuthorities)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.Add(authority);
|
||||
}
|
||||
|
||||
if (!chain.Build(certificate))
|
||||
{
|
||||
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
correlationId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
||||
|
||||
using (LogContext.PushProperty("CorrelationId", correlationId))
|
||||
{
|
||||
await next().ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(static handler =>
|
||||
{
|
||||
handler.Run(async context =>
|
||||
{
|
||||
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
||||
await result.ExecuteAsync(context);
|
||||
});
|
||||
});
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/health/ready");
|
||||
app.MapHealthChecks("/health/live");
|
||||
|
||||
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
||||
}
|
||||
|
||||
var submissionContext = BuildSubmissionContext(user, certificate);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await submissionService.SubmitAsync(request, submissionContext, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorValidationException validationEx)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: validationEx.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = validationEx.Code
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var entry = await verificationService.GetEntryAsync(uuid, refresh is true, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
uuid = entry.RekorUuid,
|
||||
index = entry.Index,
|
||||
backend = entry.Log.Backend,
|
||||
proof = entry.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Proof.Checkpoint.Origin,
|
||||
size = entry.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Proof.Inclusion.LeafHash,
|
||||
path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
logURL = entry.Log.Url,
|
||||
status = entry.Status,
|
||||
mirror = entry.Mirror is null ? null : new
|
||||
{
|
||||
backend = entry.Mirror.Backend,
|
||||
uuid = entry.Mirror.Uuid,
|
||||
index = entry.Mirror.Index,
|
||||
logURL = entry.Mirror.Url,
|
||||
status = entry.Mirror.Status,
|
||||
proof = entry.Mirror.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Mirror.Proof.Checkpoint.Origin,
|
||||
size = entry.Mirror.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
|
||||
path = entry.Mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
error = entry.Mirror.Error
|
||||
},
|
||||
artifact = new
|
||||
{
|
||||
sha256 = entry.Artifact.Sha256,
|
||||
kind = entry.Artifact.Kind,
|
||||
imageDigest = entry.Artifact.ImageDigest,
|
||||
subjectUri = entry.Artifact.SubjectUri
|
||||
}
|
||||
});
|
||||
}).RequireAuthorization("attestor:write");
|
||||
|
||||
app.MapPost("/api/v1/rekor/verify", async (AttestorVerificationRequest request, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await verificationService.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorVerificationException ex)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code
|
||||
});
|
||||
}
|
||||
}).RequireAuthorization("attestor:write");
|
||||
|
||||
app.Run();
|
||||
|
||||
static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certificate2 certificate)
|
||||
{
|
||||
var subject = user.FindFirst("sub")?.Value ?? certificate.Subject;
|
||||
var audience = user.FindFirst("aud")?.Value ?? string.Empty;
|
||||
var clientId = user.FindFirst("client_id")?.Value;
|
||||
var tenant = user.FindFirst("tenant")?.Value;
|
||||
|
||||
return new SubmissionContext
|
||||
{
|
||||
CallerSubject = subject,
|
||||
CallerAudience = audience,
|
||||
CallerClientId = clientId,
|
||||
CallerTenant = tenant,
|
||||
ClientCertificate = certificate,
|
||||
MtlsThumbprint = certificate.Thumbprint
|
||||
};
|
||||
}
|
||||
|
||||
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return certificates;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Warning("Client CA bundle '{Path}' not found", path);
|
||||
return certificates;
|
||||
}
|
||||
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(path);
|
||||
|
||||
certificates.AddRange(collection.Cast<X509Certificate2>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or CryptographicException)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
|
||||
}
|
||||
|
||||
return certificates;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
118
src/StellaOps.Attestor/StellaOps.Attestor.sln
Normal file
118
src/StellaOps.Attestor/StellaOps.Attestor.sln
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{C0FE77EB-933C-4E47-8195-758AB049157A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Infrastructure", "StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj", "{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C0FE77EB-933C-4E47-8195-758AB049157A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{996D74F8-8683-45FA-90AB-DA7ACE78D4B3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B238B098-32B1-4875-99A7-393A63AC3CCF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{988E2AC7-50E0-4845-B1C2-BA4931F2FFD7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{82EFA477-307D-4B47-A4CF-1627F076D60A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{21327A4F-2586-49F8-9D4A-3840DE64C48E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4B7592CD-D67C-4F4D-82FE-DF99BAAC4275}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
10
src/StellaOps.Attestor/TASKS.md
Normal file
10
src/StellaOps.Attestor/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Attestor Guild Task Board (UTC 2025-10-19)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTESTOR-API-11-201 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. | ✅ `POST /api/v1/rekor/entries` enforces mTLS + Authority OpTok, validates DSSE bundles, and handles dual-log preferences.<br>✅ Redis/Mongo idempotency returns existing UUID on duplicate `bundleSha256` without re-submitting to Rekor.<br>✅ Rekor driver fetches inclusion proofs (or schedules async fetch) and persists canonical entry/proof metadata.<br>✅ Optional archive path stores DSSE/proof bundles to MinIO/S3; integration tests cover success/pending/error flows. |
|
||||
| ATTESTOR-VERIFY-11-202 | DONE (2025-10-19) | Attestor Guild | — | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. | ✅ `GET /api/v1/rekor/entries/{uuid}` surfaces cached entries with optional backend refresh and handles not-found/refresh flows.<br>✅ `POST /api/v1/rekor/verify` accepts UUID, bundle, or artifact hash inputs; verifies DSSE signatures, Merkle proofs, and checkpoint anchors.<br>✅ Verification output returns `{ok, uuid, index, logURL, checkedAt}` with failure diagnostics for invalid proofs.<br>✅ Unit/integration tests exercise cache hits, backend refresh, invalid bundle/proof scenarios, and checkpoint trust anchor enforcement. |
|
||||
| ATTESTOR-OBS-11-203 | DONE (2025-10-19) | Attestor Guild | — | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. | ✅ Structured logs, metrics, and optional traces record submission latency, proof fetch outcomes, verification results, and Rekor error buckets with correlation IDs.<br>✅ mTLS enforcement hardened (peer allowlist, SAN checks, rate limiting) and documented; TLS settings audited for modern ciphers only.<br>✅ Alerting/dashboard pack covers error rates, proof backlog, Redis/Mongo health, and archive job failures; runbook updated.<br>✅ Archive workflow includes retention policy jobs, failure alerts, and periodic verification of stored bundles and proofs. |
|
||||
|
||||
> Remark (2025-10-19): Wave 0 prerequisites reviewed (none outstanding); ATTESTOR-API-11-201, ATTESTOR-VERIFY-11-202, and ATTESTOR-OBS-11-203 tracked as DOING per Wave 0A kickoff.
|
||||
> Remark (2025-10-19): Dual-log submissions, signature/proof verification, and observability hardening landed; attestor endpoints now rate-limited per client with correlation-ID logging and updated docs/tests.
|
||||
Reference in New Issue
Block a user