406 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			406 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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;
 | |
| }
 |