fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -0,0 +1,61 @@
# -----------------------------------------------------------------------------
# Dockerfile
# Sprint: SPRINT_20260125_002_Attestor_trust_automation
# Task: PROXY-008 - Docker Compose for tile-proxy stack
# Description: Multi-stage build for tile-proxy service
# -----------------------------------------------------------------------------
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution and project files
COPY ["src/Attestor/StellaOps.Attestor.TileProxy/StellaOps.Attestor.TileProxy.csproj", "Attestor/StellaOps.Attestor.TileProxy/"]
COPY ["src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/StellaOps.Attestor.Core.csproj", "Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/"]
COPY ["src/Attestor/__Libraries/StellaOps.Attestor.TrustRepo/StellaOps.Attestor.TrustRepo.csproj", "Attestor/__Libraries/StellaOps.Attestor.TrustRepo/"]
COPY ["src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj", "__Libraries/StellaOps.Configuration/"]
COPY ["src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj", "__Libraries/StellaOps.DependencyInjection/"]
# Restore dependencies
RUN dotnet restore "Attestor/StellaOps.Attestor.TileProxy/StellaOps.Attestor.TileProxy.csproj"
# Copy remaining source
COPY src/ .
# Build
WORKDIR "/src/Attestor/StellaOps.Attestor.TileProxy"
RUN dotnet build -c Release -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
# Create non-root user
RUN adduser --disabled-password --gecos "" --home /app appuser && \
mkdir -p /var/cache/stellaops/tiles && \
mkdir -p /var/cache/stellaops/tuf && \
chown -R appuser:appuser /var/cache/stellaops
# Copy published app
COPY --from=publish /app/publish .
RUN chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Configure environment
ENV ASPNETCORE_URLS=http://+:8080
ENV TILE_PROXY__CACHE__BASEPATH=/var/cache/stellaops/tiles
ENV TILE_PROXY__TUF__CACHEPATH=/var/cache/stellaops/tuf
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/_admin/health || exit 1
EXPOSE 8080
ENTRYPOINT ["dotnet", "StellaOps.Attestor.TileProxy.dll"]

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// TileEndpoints.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Tile proxy API endpoints
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Attestor.TileProxy.Services;
namespace StellaOps.Attestor.TileProxy.Endpoints;
/// <summary>
/// API endpoints for tile proxy service.
/// </summary>
public static class TileEndpoints
{
/// <summary>
/// Maps all tile proxy endpoints.
/// </summary>
public static IEndpointRouteBuilder MapTileProxyEndpoints(this IEndpointRouteBuilder endpoints)
{
// Tile endpoints (passthrough)
endpoints.MapGet("/tile/{level:int}/{index:long}", GetTile)
.WithName("GetTile")
.WithTags("Tiles")
.Produces<byte[]>(StatusCodes.Status200OK, "application/octet-stream")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status502BadGateway);
endpoints.MapGet("/tile/{level:int}/{index:long}.p/{partialWidth:int}", GetPartialTile)
.WithName("GetPartialTile")
.WithTags("Tiles")
.Produces<byte[]>(StatusCodes.Status200OK, "application/octet-stream")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status502BadGateway);
// Checkpoint endpoint
endpoints.MapGet("/checkpoint", GetCheckpoint)
.WithName("GetCheckpoint")
.WithTags("Checkpoint")
.Produces<string>(StatusCodes.Status200OK, "text/plain")
.Produces(StatusCodes.Status502BadGateway);
// Admin endpoints
var admin = endpoints.MapGroup("/_admin");
admin.MapGet("/cache/stats", GetCacheStats)
.WithName("GetCacheStats")
.WithTags("Admin")
.Produces<CacheStatsResponse>(StatusCodes.Status200OK);
admin.MapGet("/metrics", GetMetrics)
.WithName("GetMetrics")
.WithTags("Admin")
.Produces<MetricsResponse>(StatusCodes.Status200OK);
admin.MapPost("/cache/sync", TriggerSync)
.WithName("TriggerSync")
.WithTags("Admin")
.Produces<SyncResponse>(StatusCodes.Status200OK);
admin.MapDelete("/cache/prune", PruneCache)
.WithName("PruneCache")
.WithTags("Admin")
.Produces<PruneResponse>(StatusCodes.Status200OK);
admin.MapGet("/health", HealthCheck)
.WithName("HealthCheck")
.WithTags("Admin")
.Produces<HealthResponse>(StatusCodes.Status200OK);
admin.MapGet("/ready", ReadinessCheck)
.WithName("ReadinessCheck")
.WithTags("Admin")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status503ServiceUnavailable);
return endpoints;
}
private static async Task<IResult> GetTile(
int level,
long index,
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
var result = await proxyService.GetTileAsync(level, index, cancellationToken: cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
if (result.Content == null)
{
return Results.NotFound();
}
return Results.Bytes(result.Content, "application/octet-stream");
}
private static async Task<IResult> GetPartialTile(
int level,
long index,
int partialWidth,
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
if (partialWidth <= 0 || partialWidth > 256)
{
return Results.BadRequest("Invalid partial width");
}
var result = await proxyService.GetTileAsync(level, index, partialWidth, cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
if (result.Content == null)
{
return Results.NotFound();
}
return Results.Bytes(result.Content, "application/octet-stream");
}
private static async Task<IResult> GetCheckpoint(
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
var result = await proxyService.GetCheckpointAsync(cancellationToken);
if (!result.Success)
{
return Results.Problem(
detail: result.Error,
statusCode: StatusCodes.Status502BadGateway);
}
return Results.Text(result.Content ?? "", "text/plain");
}
private static async Task<IResult> GetCacheStats(
[FromServices] ContentAddressedTileStore tileStore,
CancellationToken cancellationToken)
{
var stats = await tileStore.GetStatsAsync(cancellationToken);
return Results.Ok(new CacheStatsResponse
{
TotalTiles = stats.TotalTiles,
TotalBytes = stats.TotalBytes,
TotalMb = Math.Round(stats.TotalBytes / (1024.0 * 1024.0), 2),
PartialTiles = stats.PartialTiles,
UsagePercent = Math.Round(stats.UsagePercent, 2),
OldestTile = stats.OldestTile,
NewestTile = stats.NewestTile
});
}
private static IResult GetMetrics(
[FromServices] TileProxyService proxyService)
{
var metrics = proxyService.GetMetrics();
return Results.Ok(new MetricsResponse
{
CacheHits = metrics.CacheHits,
CacheMisses = metrics.CacheMisses,
HitRatePercent = Math.Round(metrics.HitRate, 2),
UpstreamRequests = metrics.UpstreamRequests,
UpstreamErrors = metrics.UpstreamErrors,
InflightRequests = metrics.InflightRequests
});
}
private static IResult TriggerSync(
[FromServices] IServiceProvider services,
[FromServices] ILogger<TileEndpoints> logger)
{
// TODO: Trigger background sync job
logger.LogInformation("Manual sync triggered");
return Results.Ok(new SyncResponse
{
Message = "Sync job queued",
QueuedAt = DateTimeOffset.UtcNow
});
}
private static async Task<IResult> PruneCache(
[FromServices] ContentAddressedTileStore tileStore,
[FromQuery] long? targetSizeBytes,
CancellationToken cancellationToken)
{
var prunedCount = await tileStore.PruneAsync(targetSizeBytes ?? 0, cancellationToken);
return Results.Ok(new PruneResponse
{
TilesPruned = prunedCount,
PrunedAt = DateTimeOffset.UtcNow
});
}
private static IResult HealthCheck()
{
return Results.Ok(new HealthResponse
{
Status = "healthy",
Timestamp = DateTimeOffset.UtcNow
});
}
private static async Task<IResult> ReadinessCheck(
[FromServices] TileProxyService proxyService,
CancellationToken cancellationToken)
{
// Check if we can reach upstream
var checkpoint = await proxyService.GetCheckpointAsync(cancellationToken);
if (checkpoint.Success)
{
return Results.Ok(new { ready = true, checkpoint = checkpoint.TreeSize });
}
return Results.Json(
new { ready = false, error = checkpoint.Error },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
// Response models
public sealed record CacheStatsResponse
{
public int TotalTiles { get; init; }
public long TotalBytes { get; init; }
public double TotalMb { get; init; }
public int PartialTiles { get; init; }
public double UsagePercent { get; init; }
public DateTimeOffset? OldestTile { get; init; }
public DateTimeOffset? NewestTile { get; init; }
}
public sealed record MetricsResponse
{
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public double HitRatePercent { get; init; }
public long UpstreamRequests { get; init; }
public long UpstreamErrors { get; init; }
public int InflightRequests { get; init; }
}
public sealed record SyncResponse
{
public string Message { get; init; } = string.Empty;
public DateTimeOffset QueuedAt { get; init; }
}
public sealed record PruneResponse
{
public int TilesPruned { get; init; }
public DateTimeOffset PrunedAt { get; init; }
}
public sealed record HealthResponse
{
public string Status { get; init; } = string.Empty;
public DateTimeOffset Timestamp { get; init; }
}
// Logger class for endpoint logging
file static class TileEndpoints
{
}

View File

@@ -0,0 +1,278 @@
// -----------------------------------------------------------------------------
// TileSyncJob.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-006 - Implement scheduled tile sync job
// Description: Background job for pre-warming tile cache
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.TileProxy.Services;
namespace StellaOps.Attestor.TileProxy.Jobs;
/// <summary>
/// Background job that periodically syncs tiles from upstream to pre-warm the cache.
/// </summary>
public sealed class TileSyncJob : BackgroundService
{
private readonly TileProxyOptions _options;
private readonly TileProxyService _proxyService;
private readonly ContentAddressedTileStore _tileStore;
private readonly ILogger<TileSyncJob> _logger;
private const int TileWidth = 256;
public TileSyncJob(
IOptions<TileProxyOptions> options,
TileProxyService proxyService,
ContentAddressedTileStore tileStore,
ILogger<TileSyncJob> logger)
{
_options = options.Value;
_proxyService = proxyService;
_tileStore = tileStore;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Sync.Enabled)
{
_logger.LogInformation("Tile sync job is disabled");
return;
}
_logger.LogInformation(
"Tile sync job started - Schedule: {Schedule}, Depth: {Depth}",
_options.Sync.Schedule,
_options.Sync.Depth);
// Run initial sync on startup
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
await RunSyncAsync(stoppingToken);
// Schedule periodic sync
var schedule = ParseCronSchedule(_options.Sync.Schedule);
while (!stoppingToken.IsCancellationRequested)
{
var nextRun = GetNextRunTime(schedule);
var delay = nextRun - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero)
{
_logger.LogDebug("Next sync scheduled at {NextRun}", nextRun);
await Task.Delay(delay, stoppingToken);
}
if (!stoppingToken.IsCancellationRequested)
{
await RunSyncAsync(stoppingToken);
}
}
}
/// <summary>
/// Runs a sync operation to pre-warm the tile cache.
/// </summary>
public async Task RunSyncAsync(CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
_logger.LogInformation("Starting tile sync");
try
{
// Fetch current checkpoint
var checkpoint = await _proxyService.GetCheckpointAsync(cancellationToken);
if (!checkpoint.Success || !checkpoint.TreeSize.HasValue)
{
_logger.LogWarning("Failed to fetch checkpoint: {Error}", checkpoint.Error);
return;
}
var treeSize = checkpoint.TreeSize.Value;
var depth = Math.Min(_options.Sync.Depth, treeSize);
_logger.LogInformation(
"Syncing tiles for entries {StartIndex} to {EndIndex} (tree size: {TreeSize})",
treeSize - depth,
treeSize,
treeSize);
// Calculate which tiles we need for the specified depth
var tilesToSync = CalculateRequiredTiles(treeSize - depth, treeSize);
var syncedCount = 0;
var skippedCount = 0;
var errorCount = 0;
foreach (var (level, index) in tilesToSync)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
// Check if we already have this tile
var hasTile = await _tileStore.HasTileAsync(_options.Origin, level, index, cancellationToken);
if (hasTile)
{
skippedCount++;
continue;
}
// Fetch the tile
var result = await _proxyService.GetTileAsync(level, index, cancellationToken: cancellationToken);
if (result.Success)
{
syncedCount++;
}
else
{
errorCount++;
_logger.LogWarning("Failed to sync tile {Level}/{Index}: {Error}", level, index, result.Error);
}
// Rate limiting to avoid overwhelming upstream
await Task.Delay(50, cancellationToken);
}
var duration = DateTimeOffset.UtcNow - startTime;
_logger.LogInformation(
"Tile sync completed in {Duration}ms - Synced: {Synced}, Skipped: {Skipped}, Errors: {Errors}",
duration.TotalMilliseconds,
syncedCount,
skippedCount,
errorCount);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Tile sync cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Tile sync failed");
}
}
private static List<(int Level, long Index)> CalculateRequiredTiles(long startIndex, long endIndex)
{
var tiles = new HashSet<(int Level, long Index)>();
// Level 0: tiles containing the entries
var startTile = startIndex / TileWidth;
var endTile = (endIndex - 1) / TileWidth;
for (var i = startTile; i <= endTile; i++)
{
tiles.Add((0, i));
}
// Higher levels: tiles needed for Merkle proofs
var level = 1;
var levelStart = startTile;
var levelEnd = endTile;
while (levelStart < levelEnd)
{
levelStart /= TileWidth;
levelEnd /= TileWidth;
for (var i = levelStart; i <= levelEnd; i++)
{
tiles.Add((level, i));
}
level++;
}
return tiles.OrderBy(t => t.Level).ThenBy(t => t.Index).ToList();
}
private static CronSchedule ParseCronSchedule(string schedule)
{
// Simple cron parser for "minute hour day month weekday" format
var parts = schedule.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5)
{
throw new ArgumentException($"Invalid cron schedule: {schedule}");
}
return new CronSchedule
{
Minute = ParseCronField(parts[0], 0, 59),
Hour = ParseCronField(parts[1], 0, 23),
Day = ParseCronField(parts[2], 1, 31),
Month = ParseCronField(parts[3], 1, 12),
Weekday = ParseCronField(parts[4], 0, 6)
};
}
private static int[] ParseCronField(string field, int min, int max)
{
if (field == "*")
{
return Enumerable.Range(min, max - min + 1).ToArray();
}
if (field.StartsWith("*/"))
{
var interval = int.Parse(field[2..]);
return Enumerable.Range(min, max - min + 1)
.Where(i => (i - min) % interval == 0)
.ToArray();
}
if (field.Contains(','))
{
return field.Split(',').Select(int.Parse).ToArray();
}
if (field.Contains('-'))
{
var range = field.Split('-');
var start = int.Parse(range[0]);
var end = int.Parse(range[1]);
return Enumerable.Range(start, end - start + 1).ToArray();
}
return [int.Parse(field)];
}
private static DateTimeOffset GetNextRunTime(CronSchedule schedule)
{
var now = DateTimeOffset.UtcNow;
var candidate = new DateTimeOffset(
now.Year, now.Month, now.Day,
now.Hour, now.Minute, 0,
TimeSpan.Zero);
// Search for next valid time within the next year
for (var i = 0; i < 525600; i++) // Max ~1 year in minutes
{
candidate = candidate.AddMinutes(1);
if (schedule.Minute.Contains(candidate.Minute) &&
schedule.Hour.Contains(candidate.Hour) &&
schedule.Day.Contains(candidate.Day) &&
schedule.Month.Contains(candidate.Month) &&
schedule.Weekday.Contains((int)candidate.DayOfWeek))
{
return candidate;
}
}
// Fallback: run in 6 hours
return now.AddHours(6);
}
private sealed record CronSchedule
{
public required int[] Minute { get; init; }
public required int[] Hour { get; init; }
public required int[] Day { get; init; }
public required int[] Month { get; init; }
public required int[] Weekday { get; init; }
}
}

View File

@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------------
// Program.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Tile proxy web service entry point
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Options;
using Serilog;
using StellaOps.Attestor.TileProxy;
using StellaOps.Attestor.TileProxy.Endpoints;
using StellaOps.Attestor.TileProxy.Jobs;
using StellaOps.Attestor.TileProxy.Services;
const string ConfigurationSection = "tile_proxy";
var builder = WebApplication.CreateBuilder(args);
// Configure logging
builder.Host.UseSerilog((context, config) =>
{
config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
});
// Load configuration
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("TILE_PROXY__");
// Configure options
builder.Services.Configure<TileProxyOptions>(builder.Configuration.GetSection(ConfigurationSection));
// Validate options
builder.Services.AddSingleton<IValidateOptions<TileProxyOptions>, TileProxyOptionsValidator>();
// Register services
builder.Services.AddSingleton<ContentAddressedTileStore>();
builder.Services.AddSingleton<TileProxyService>();
// Register sync job as hosted service
builder.Services.AddHostedService<TileSyncJob>();
// Configure HTTP client for upstream
builder.Services.AddHttpClient<TileProxyService>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TileProxyOptions>>().Value;
client.BaseAddress = new Uri(options.UpstreamUrl);
client.Timeout = TimeSpan.FromSeconds(options.Request.TimeoutSeconds);
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-TileProxy/1.0");
});
// Add OpenAPI
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Validate options on startup
var optionsValidator = app.Services.GetRequiredService<IValidateOptions<TileProxyOptions>>();
var options = app.Services.GetRequiredService<IOptions<TileProxyOptions>>().Value;
var validationResult = optionsValidator.Validate(null, options);
if (validationResult.Failed)
{
throw new InvalidOperationException($"Configuration validation failed: {validationResult.FailureMessage}");
}
// Configure pipeline
app.UseSerilogRequestLogging();
// Map endpoints
app.MapTileProxyEndpoints();
// Startup message
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation(
"Tile Proxy starting - Upstream: {Upstream}, Cache: {CachePath}",
options.UpstreamUrl,
options.Cache.BasePath);
app.Run();
/// <summary>
/// Options validator for tile proxy configuration.
/// </summary>
public sealed class TileProxyOptionsValidator : IValidateOptions<TileProxyOptions>
{
public ValidateOptionsResult Validate(string? name, TileProxyOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.UpstreamUrl))
{
errors.Add("UpstreamUrl is required");
}
else if (!Uri.TryCreate(options.UpstreamUrl, UriKind.Absolute, out _))
{
errors.Add("UpstreamUrl must be a valid absolute URI");
}
if (string.IsNullOrWhiteSpace(options.Origin))
{
errors.Add("Origin is required");
}
if (options.Cache.MaxSizeGb < 0)
{
errors.Add("Cache.MaxSizeGb cannot be negative");
}
if (options.Cache.CheckpointTtlMinutes < 1)
{
errors.Add("Cache.CheckpointTtlMinutes must be at least 1");
}
if (options.Request.TimeoutSeconds < 1)
{
errors.Add("Request.TimeoutSeconds must be at least 1");
}
if (options.Tuf.Enabled && string.IsNullOrWhiteSpace(options.Tuf.Url))
{
errors.Add("Tuf.Url is required when TUF is enabled");
}
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}
public partial class Program
{
}

View File

@@ -0,0 +1,433 @@
// -----------------------------------------------------------------------------
// ContentAddressedTileStore.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Content-addressed storage for cached tiles
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TileProxy.Services;
/// <summary>
/// Content-addressed storage for transparency log tiles.
/// Provides immutable, deduplicated tile caching with metadata.
/// </summary>
public sealed class ContentAddressedTileStore : IDisposable
{
private readonly TileProxyOptions _options;
private readonly ILogger<ContentAddressedTileStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, DateTimeOffset> _accessTimes = new();
private const int TileWidth = 256;
private const int HashSize = 32;
public ContentAddressedTileStore(
IOptions<TileProxyOptions> options,
ILogger<ContentAddressedTileStore> logger)
{
_options = options.Value;
_logger = logger;
// Ensure base directory exists
Directory.CreateDirectory(_options.Cache.BasePath);
}
/// <summary>
/// Gets a tile from the cache.
/// </summary>
public async Task<CachedTileData?> GetTileAsync(
string origin,
int level,
long index,
CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
var metaPath = GetMetaPath(origin, level, index);
if (!File.Exists(tilePath))
{
return null;
}
try
{
var content = await File.ReadAllBytesAsync(tilePath, cancellationToken);
TileMetadata? meta = null;
if (File.Exists(metaPath))
{
var metaJson = await File.ReadAllTextAsync(metaPath, cancellationToken);
meta = JsonSerializer.Deserialize<TileMetadata>(metaJson);
}
// Update access time for LRU
var key = $"{origin}/{level}/{index}";
_accessTimes[key] = DateTimeOffset.UtcNow;
return new CachedTileData
{
Origin = origin,
Level = level,
Index = index,
Content = content,
Width = content.Length / HashSize,
CachedAt = meta?.CachedAt ?? File.GetCreationTimeUtc(tilePath),
TreeSize = meta?.TreeSize,
ContentHash = meta?.ContentHash,
IsPartial = content.Length / HashSize < TileWidth
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached tile {Origin}/{Level}/{Index}", origin, level, index);
return null;
}
}
/// <summary>
/// Stores a tile in the cache.
/// </summary>
public async Task StoreTileAsync(
string origin,
int level,
long index,
byte[] content,
long? treeSize = null,
CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
var metaPath = GetMetaPath(origin, level, index);
var tileDir = Path.GetDirectoryName(tilePath)!;
var contentHash = ComputeContentHash(content);
await _writeLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(tileDir);
// Atomic write using temp file
var tempPath = tilePath + ".tmp";
await File.WriteAllBytesAsync(tempPath, content, cancellationToken);
File.Move(tempPath, tilePath, overwrite: true);
// Write metadata
var meta = new TileMetadata
{
CachedAt = DateTimeOffset.UtcNow,
TreeSize = treeSize,
ContentHash = contentHash,
IsPartial = content.Length / HashSize < TileWidth,
Width = content.Length / HashSize
};
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(metaPath, metaJson, cancellationToken);
_logger.LogDebug(
"Cached tile {Origin}/{Level}/{Index} ({Bytes} bytes, hash: {Hash})",
origin, level, index, content.Length, contentHash[..16]);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Checks if a tile exists in the cache.
/// </summary>
public Task<bool> HasTileAsync(string origin, int level, long index, CancellationToken cancellationToken = default)
{
var tilePath = GetTilePath(origin, level, index);
return Task.FromResult(File.Exists(tilePath));
}
/// <summary>
/// Gets a checkpoint from the cache.
/// </summary>
public async Task<CachedCheckpoint?> GetCheckpointAsync(
string origin,
CancellationToken cancellationToken = default)
{
var checkpointPath = GetCheckpointPath(origin);
var metaPath = checkpointPath + ".meta.json";
if (!File.Exists(checkpointPath))
{
return null;
}
try
{
var content = await File.ReadAllTextAsync(checkpointPath, cancellationToken);
CachedCheckpoint? meta = null;
if (File.Exists(metaPath))
{
var metaJson = await File.ReadAllTextAsync(metaPath, cancellationToken);
meta = JsonSerializer.Deserialize<CachedCheckpoint>(metaJson);
}
// Check TTL
var cachedAt = meta?.CachedAt ?? File.GetCreationTimeUtc(checkpointPath);
var age = DateTimeOffset.UtcNow - cachedAt;
if (age.TotalMinutes > _options.Cache.CheckpointTtlMinutes)
{
_logger.LogDebug("Checkpoint for {Origin} is stale (age: {Age})", origin, age);
return null;
}
return new CachedCheckpoint
{
Origin = origin,
Content = content,
CachedAt = cachedAt,
TreeSize = meta?.TreeSize,
RootHash = meta?.RootHash
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached checkpoint for {Origin}", origin);
return null;
}
}
/// <summary>
/// Stores a checkpoint in the cache.
/// </summary>
public async Task StoreCheckpointAsync(
string origin,
string content,
long? treeSize = null,
string? rootHash = null,
CancellationToken cancellationToken = default)
{
var checkpointPath = GetCheckpointPath(origin);
var metaPath = checkpointPath + ".meta.json";
var checkpointDir = Path.GetDirectoryName(checkpointPath)!;
await _writeLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(checkpointDir);
await File.WriteAllTextAsync(checkpointPath, content, cancellationToken);
var meta = new CachedCheckpoint
{
Origin = origin,
Content = content,
CachedAt = DateTimeOffset.UtcNow,
TreeSize = treeSize,
RootHash = rootHash
};
var metaJson = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(metaPath, metaJson, cancellationToken);
_logger.LogDebug("Cached checkpoint for {Origin} (tree size: {TreeSize})", origin, treeSize);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public Task<TileCacheStats> GetStatsAsync(CancellationToken cancellationToken = default)
{
var basePath = _options.Cache.BasePath;
if (!Directory.Exists(basePath))
{
return Task.FromResult(new TileCacheStats());
}
var tileFiles = Directory.GetFiles(basePath, "*.tile", SearchOption.AllDirectories);
long totalBytes = 0;
int partialTiles = 0;
DateTimeOffset? oldestTile = null;
DateTimeOffset? newestTile = null;
foreach (var file in tileFiles)
{
var info = new FileInfo(file);
totalBytes += info.Length;
var creationTime = new DateTimeOffset(info.CreationTimeUtc, TimeSpan.Zero);
oldestTile = oldestTile == null ? creationTime : (creationTime < oldestTile ? creationTime : oldestTile);
newestTile = newestTile == null ? creationTime : (creationTime > newestTile ? creationTime : newestTile);
if (info.Length / HashSize < TileWidth)
{
partialTiles++;
}
}
return Task.FromResult(new TileCacheStats
{
TotalTiles = tileFiles.Length,
TotalBytes = totalBytes,
PartialTiles = partialTiles,
OldestTile = oldestTile,
NewestTile = newestTile,
MaxSizeBytes = _options.Cache.MaxSizeBytes
});
}
/// <summary>
/// Prunes tiles based on eviction policy.
/// </summary>
public async Task<int> PruneAsync(long targetSizeBytes, CancellationToken cancellationToken = default)
{
var stats = await GetStatsAsync(cancellationToken);
if (stats.TotalBytes <= targetSizeBytes)
{
return 0;
}
var bytesToFree = stats.TotalBytes - targetSizeBytes;
var tileFiles = Directory.GetFiles(_options.Cache.BasePath, "*.tile", SearchOption.AllDirectories)
.Select(f => new FileInfo(f))
.OrderBy(f => _accessTimes.GetValueOrDefault($"{f.Directory?.Parent?.Name}/{f.Directory?.Name}/{Path.GetFileNameWithoutExtension(f.Name)}", f.CreationTimeUtc))
.ToList();
long freedBytes = 0;
int prunedCount = 0;
await _writeLock.WaitAsync(cancellationToken);
try
{
foreach (var file in tileFiles)
{
if (freedBytes >= bytesToFree)
{
break;
}
try
{
var metaPath = Path.ChangeExtension(file.FullName, ".meta.json");
freedBytes += file.Length;
file.Delete();
if (File.Exists(metaPath))
{
File.Delete(metaPath);
}
prunedCount++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prune tile {File}", file.FullName);
}
}
}
finally
{
_writeLock.Release();
}
_logger.LogInformation("Pruned {Count} tiles, freed {Bytes} bytes", prunedCount, freedBytes);
return prunedCount;
}
private string GetOriginPath(string origin)
{
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(origin));
var hashHex = Convert.ToHexString(hash)[..16];
var readable = new string(origin
.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')
.Take(32)
.ToArray());
return Path.Combine(_options.Cache.BasePath, string.IsNullOrEmpty(readable) ? hashHex : $"{readable}_{hashHex}");
}
private string GetTilePath(string origin, int level, long index)
{
return Path.Combine(GetOriginPath(origin), "tiles", level.ToString(), $"{index}.tile");
}
private string GetMetaPath(string origin, int level, long index)
{
return Path.Combine(GetOriginPath(origin), "tiles", level.ToString(), $"{index}.meta.json");
}
private string GetCheckpointPath(string origin)
{
return Path.Combine(GetOriginPath(origin), "checkpoint");
}
private static string ComputeContentHash(byte[] content)
{
var hash = SHA256.HashData(content);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public void Dispose()
{
_writeLock.Dispose();
}
private sealed record TileMetadata
{
public DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? ContentHash { get; init; }
public bool IsPartial { get; init; }
public int Width { get; init; }
}
}
/// <summary>
/// Cached tile data.
/// </summary>
public sealed record CachedTileData
{
public required string Origin { get; init; }
public required int Level { get; init; }
public required long Index { get; init; }
public required byte[] Content { get; init; }
public required int Width { get; init; }
public required DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? ContentHash { get; init; }
public bool IsPartial { get; init; }
}
/// <summary>
/// Cached checkpoint data.
/// </summary>
public sealed record CachedCheckpoint
{
public string Origin { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public DateTimeOffset CachedAt { get; init; }
public long? TreeSize { get; init; }
public string? RootHash { get; init; }
}
/// <summary>
/// Tile cache statistics.
/// </summary>
public sealed record TileCacheStats
{
public int TotalTiles { get; init; }
public long TotalBytes { get; init; }
public int PartialTiles { get; init; }
public DateTimeOffset? OldestTile { get; init; }
public DateTimeOffset? NewestTile { get; init; }
public long MaxSizeBytes { get; init; }
public double UsagePercent => MaxSizeBytes > 0 ? (double)TotalBytes / MaxSizeBytes * 100 : 0;
}

View File

@@ -0,0 +1,409 @@
// -----------------------------------------------------------------------------
// TileProxyService.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Core tile proxy service with request coalescing
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TileProxy.Services;
/// <summary>
/// Core tile proxy service that fetches tiles from upstream and manages caching.
/// Supports request coalescing to avoid duplicate upstream requests.
/// </summary>
public sealed partial class TileProxyService : IDisposable
{
private readonly TileProxyOptions _options;
private readonly ContentAddressedTileStore _tileStore;
private readonly HttpClient _httpClient;
private readonly ILogger<TileProxyService> _logger;
private readonly ConcurrentDictionary<string, Task<byte[]>> _inflightTileRequests = new();
private readonly ConcurrentDictionary<string, Task<string>> _inflightCheckpointRequests = new();
private readonly SemaphoreSlim _coalesceGuard = new(1, 1);
// Metrics
private long _cacheHits;
private long _cacheMisses;
private long _upstreamRequests;
private long _upstreamErrors;
public TileProxyService(
IOptions<TileProxyOptions> options,
ContentAddressedTileStore tileStore,
HttpClient httpClient,
ILogger<TileProxyService> logger)
{
_options = options.Value;
_tileStore = tileStore;
_httpClient = httpClient;
_logger = logger;
_httpClient.Timeout = TimeSpan.FromSeconds(_options.Request.TimeoutSeconds);
}
/// <summary>
/// Gets a tile, fetching from upstream if not cached.
/// </summary>
public async Task<TileProxyResult> GetTileAsync(
int level,
long index,
int? partialWidth = null,
CancellationToken cancellationToken = default)
{
var origin = _options.Origin;
// Check cache first
var cached = await _tileStore.GetTileAsync(origin, level, index, cancellationToken);
if (cached != null)
{
// For partial tiles, check if we have enough data
if (partialWidth == null || cached.Width >= partialWidth)
{
Interlocked.Increment(ref _cacheHits);
_logger.LogDebug("Cache hit for tile {Level}/{Index}", level, index);
var content = cached.Content;
if (partialWidth.HasValue && cached.Width > partialWidth)
{
// Return only the requested portion
content = content[..(partialWidth.Value * 32)];
}
return new TileProxyResult
{
Success = true,
Content = content,
FromCache = true,
Level = level,
Index = index
};
}
}
Interlocked.Increment(ref _cacheMisses);
// Fetch from upstream (with coalescing)
var key = $"tile/{level}/{index}";
if (partialWidth.HasValue)
{
key += $".p/{partialWidth}";
}
try
{
byte[] tileContent;
if (_options.Request.CoalescingEnabled)
{
// Check for in-flight request
if (_inflightTileRequests.TryGetValue(key, out var existingTask))
{
_logger.LogDebug("Coalescing request for tile {Key}", key);
tileContent = await existingTask;
}
else
{
var fetchTask = FetchTileFromUpstreamAsync(level, index, partialWidth, cancellationToken);
if (_inflightTileRequests.TryAdd(key, fetchTask))
{
try
{
tileContent = await fetchTask;
}
finally
{
_inflightTileRequests.TryRemove(key, out _);
}
}
else
{
// Another thread added it; wait for that one
tileContent = await _inflightTileRequests[key];
}
}
}
else
{
tileContent = await FetchTileFromUpstreamAsync(level, index, partialWidth, cancellationToken);
}
// Cache the tile (only full tiles or if we got the full content)
if (partialWidth == null)
{
await _tileStore.StoreTileAsync(origin, level, index, tileContent, cancellationToken: cancellationToken);
}
return new TileProxyResult
{
Success = true,
Content = tileContent,
FromCache = false,
Level = level,
Index = index
};
}
catch (Exception ex)
{
Interlocked.Increment(ref _upstreamErrors);
_logger.LogWarning(ex, "Failed to fetch tile {Level}/{Index} from upstream", level, index);
// Return cached partial if available
if (cached != null)
{
_logger.LogInformation("Returning stale cached tile {Level}/{Index}", level, index);
return new TileProxyResult
{
Success = true,
Content = cached.Content,
FromCache = true,
Stale = true,
Level = level,
Index = index
};
}
return new TileProxyResult
{
Success = false,
Error = ex.Message,
Level = level,
Index = index
};
}
}
/// <summary>
/// Gets the current checkpoint.
/// </summary>
public async Task<CheckpointProxyResult> GetCheckpointAsync(CancellationToken cancellationToken = default)
{
var origin = _options.Origin;
// Check cache first (with TTL check)
var cached = await _tileStore.GetCheckpointAsync(origin, cancellationToken);
if (cached != null)
{
Interlocked.Increment(ref _cacheHits);
_logger.LogDebug("Cache hit for checkpoint");
return new CheckpointProxyResult
{
Success = true,
Content = cached.Content,
FromCache = true,
TreeSize = cached.TreeSize,
RootHash = cached.RootHash
};
}
Interlocked.Increment(ref _cacheMisses);
// Fetch from upstream
var key = "checkpoint";
try
{
string checkpointContent;
if (_options.Request.CoalescingEnabled)
{
if (_inflightCheckpointRequests.TryGetValue(key, out var existingTask))
{
_logger.LogDebug("Coalescing request for checkpoint");
checkpointContent = await existingTask;
}
else
{
var fetchTask = FetchCheckpointFromUpstreamAsync(cancellationToken);
if (_inflightCheckpointRequests.TryAdd(key, fetchTask))
{
try
{
checkpointContent = await fetchTask;
}
finally
{
_inflightCheckpointRequests.TryRemove(key, out _);
}
}
else
{
checkpointContent = await _inflightCheckpointRequests[key];
}
}
}
else
{
checkpointContent = await FetchCheckpointFromUpstreamAsync(cancellationToken);
}
// Parse checkpoint for tree size and root hash
var (treeSize, rootHash) = ParseCheckpoint(checkpointContent);
// Cache the checkpoint
await _tileStore.StoreCheckpointAsync(origin, checkpointContent, treeSize, rootHash, cancellationToken);
return new CheckpointProxyResult
{
Success = true,
Content = checkpointContent,
FromCache = false,
TreeSize = treeSize,
RootHash = rootHash
};
}
catch (Exception ex)
{
Interlocked.Increment(ref _upstreamErrors);
_logger.LogWarning(ex, "Failed to fetch checkpoint from upstream");
return new CheckpointProxyResult
{
Success = false,
Error = ex.Message
};
}
}
/// <summary>
/// Gets proxy metrics.
/// </summary>
public TileProxyMetrics GetMetrics()
{
return new TileProxyMetrics
{
CacheHits = _cacheHits,
CacheMisses = _cacheMisses,
UpstreamRequests = _upstreamRequests,
UpstreamErrors = _upstreamErrors,
InflightRequests = _inflightTileRequests.Count + _inflightCheckpointRequests.Count
};
}
private async Task<byte[]> FetchTileFromUpstreamAsync(
int level,
long index,
int? partialWidth,
CancellationToken cancellationToken)
{
var tileBaseUrl = _options.GetTileBaseUrl();
var url = $"{tileBaseUrl}/{level}/{index}";
if (partialWidth.HasValue)
{
url += $".p/{partialWidth}";
}
_logger.LogDebug("Fetching tile from upstream: {Url}", url);
Interlocked.Increment(ref _upstreamRequests);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
private async Task<string> FetchCheckpointFromUpstreamAsync(CancellationToken cancellationToken)
{
var checkpointUrl = $"{_options.UpstreamUrl.TrimEnd('/')}/checkpoint";
_logger.LogDebug("Fetching checkpoint from upstream: {Url}", checkpointUrl);
Interlocked.Increment(ref _upstreamRequests);
using var request = new HttpRequestMessage(HttpMethod.Get, checkpointUrl);
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
private static (long? treeSize, string? rootHash) ParseCheckpoint(string checkpoint)
{
// Checkpoint format (Sigstore):
// rekor.sigstore.dev - 1985497715
// 123456789
// abc123def456...
//
// — rekor.sigstore.dev wNI9ajBFAi...
var lines = checkpoint.Split('\n', StringSplitOptions.RemoveEmptyEntries);
long? treeSize = null;
string? rootHash = null;
if (lines.Length >= 2 && long.TryParse(lines[1].Trim(), out var size))
{
treeSize = size;
}
if (lines.Length >= 3)
{
var hashLine = lines[2].Trim();
if (HashLineRegex().IsMatch(hashLine))
{
rootHash = hashLine;
}
}
return (treeSize, rootHash);
}
[GeneratedRegex(@"^[a-fA-F0-9]{64}$")]
private static partial Regex HashLineRegex();
public void Dispose()
{
_coalesceGuard.Dispose();
}
}
/// <summary>
/// Result of a tile proxy request.
/// </summary>
public sealed record TileProxyResult
{
public bool Success { get; init; }
public byte[]? Content { get; init; }
public bool FromCache { get; init; }
public bool Stale { get; init; }
public string? Error { get; init; }
public int Level { get; init; }
public long Index { get; init; }
}
/// <summary>
/// Result of a checkpoint proxy request.
/// </summary>
public sealed record CheckpointProxyResult
{
public bool Success { get; init; }
public string? Content { get; init; }
public bool FromCache { get; init; }
public long? TreeSize { get; init; }
public string? RootHash { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Tile proxy metrics.
/// </summary>
public sealed record TileProxyMetrics
{
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public long UpstreamRequests { get; init; }
public long UpstreamErrors { get; init; }
public int InflightRequests { get; init; }
public double HitRate => CacheHits + CacheMisses > 0
? (double)CacheHits / (CacheHits + CacheMisses) * 100
: 0;
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
StellaOps.Attestor.TileProxy.csproj
Sprint: SPRINT_20260125_002_Attestor_trust_automation
Task: PROXY-002 - Implement tile-proxy service
Description: Tile caching proxy for Rekor transparency log
-->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Attestor.TileProxy</RootNamespace>
<AssemblyName>StellaOps.Attestor.TileProxy</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Attestor.TrustRepo\StellaOps.Attestor.TrustRepo.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,198 @@
// -----------------------------------------------------------------------------
// TileProxyOptions.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-002 - Implement tile-proxy service
// Description: Configuration options for tile-proxy service
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Attestor.TileProxy;
/// <summary>
/// Configuration options for the tile-proxy service.
/// </summary>
public sealed record TileProxyOptions
{
/// <summary>
/// Upstream Rekor URL for tile fetching.
/// </summary>
[Required]
public string UpstreamUrl { get; init; } = "https://rekor.sigstore.dev";
/// <summary>
/// Base URL for tile API (if different from UpstreamUrl).
/// </summary>
public string? TileBaseUrl { get; init; }
/// <summary>
/// Origin identifier for the transparency log.
/// </summary>
public string Origin { get; init; } = "rekor.sigstore.dev - 1985497715";
/// <summary>
/// Cache configuration options.
/// </summary>
public TileProxyCacheOptions Cache { get; init; } = new();
/// <summary>
/// TUF integration options.
/// </summary>
public TileProxyTufOptions Tuf { get; init; } = new();
/// <summary>
/// Sync job options.
/// </summary>
public TileProxySyncOptions Sync { get; init; } = new();
/// <summary>
/// Request handling options.
/// </summary>
public TileProxyRequestOptions Request { get; init; } = new();
/// <summary>
/// Failover configuration.
/// </summary>
public TileProxyFailoverOptions Failover { get; init; } = new();
/// <summary>
/// Gets the effective tile base URL.
/// </summary>
public string GetTileBaseUrl()
{
if (!string.IsNullOrEmpty(TileBaseUrl))
{
return TileBaseUrl.TrimEnd('/');
}
var upstreamUri = new Uri(UpstreamUrl);
return new Uri(upstreamUri, "/tile/").ToString().TrimEnd('/');
}
}
/// <summary>
/// Cache configuration options.
/// </summary>
public sealed record TileProxyCacheOptions
{
/// <summary>
/// Base path for tile cache storage.
/// </summary>
public string BasePath { get; init; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"StellaOps", "TileProxy", "Tiles");
/// <summary>
/// Maximum cache size in gigabytes (0 = unlimited).
/// </summary>
public double MaxSizeGb { get; init; } = 10;
/// <summary>
/// Eviction policy: lru or time.
/// </summary>
public string EvictionPolicy { get; init; } = "lru";
/// <summary>
/// Checkpoint TTL in minutes (how long to cache checkpoints).
/// </summary>
public int CheckpointTtlMinutes { get; init; } = 5;
/// <summary>
/// Gets max cache size in bytes.
/// </summary>
public long MaxSizeBytes => (long)(MaxSizeGb * 1024 * 1024 * 1024);
}
/// <summary>
/// TUF integration options.
/// </summary>
public sealed record TileProxyTufOptions
{
/// <summary>
/// Whether TUF integration is enabled.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// TUF repository URL.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// Whether to validate checkpoint signatures.
/// </summary>
public bool ValidateCheckpointSignature { get; init; } = true;
}
/// <summary>
/// Sync job configuration.
/// </summary>
public sealed record TileProxySyncOptions
{
/// <summary>
/// Whether scheduled sync is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Cron schedule for sync job.
/// </summary>
public string Schedule { get; init; } = "0 */6 * * *";
/// <summary>
/// Number of recent entries to sync tiles for.
/// </summary>
public int Depth { get; init; } = 10000;
/// <summary>
/// Checkpoint refresh interval in minutes.
/// </summary>
public int CheckpointIntervalMinutes { get; init; } = 60;
}
/// <summary>
/// Request handling options.
/// </summary>
public sealed record TileProxyRequestOptions
{
/// <summary>
/// Whether request coalescing is enabled.
/// </summary>
public bool CoalescingEnabled { get; init; } = true;
/// <summary>
/// Maximum wait time for coalesced requests in milliseconds.
/// </summary>
public int CoalescingMaxWaitMs { get; init; } = 5000;
/// <summary>
/// Request timeout for upstream calls in seconds.
/// </summary>
public int TimeoutSeconds { get; init; } = 30;
}
/// <summary>
/// Failover configuration.
/// </summary>
public sealed record TileProxyFailoverOptions
{
/// <summary>
/// Whether failover is enabled.
/// </summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Number of retry attempts.
/// </summary>
public int RetryCount { get; init; } = 2;
/// <summary>
/// Delay between retries in milliseconds.
/// </summary>
public int RetryDelayMs { get; init; } = 1000;
/// <summary>
/// Additional upstream URLs for failover.
/// </summary>
public List<string> AdditionalUpstreams { get; init; } = [];
}

View File

@@ -0,0 +1,41 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
}
},
"tile_proxy": {
"upstream_url": "https://rekor.sigstore.dev",
"origin": "rekor.sigstore.dev - 1985497715",
"cache": {
"max_size_gb": 10,
"eviction_policy": "lru",
"checkpoint_ttl_minutes": 5
},
"tuf": {
"enabled": false,
"validate_checkpoint_signature": true
},
"sync": {
"enabled": true,
"schedule": "0 */6 * * *",
"depth": 10000,
"checkpoint_interval_minutes": 60
},
"request": {
"coalescing_enabled": true,
"coalescing_max_wait_ms": 5000,
"timeout_seconds": 30
},
"failover": {
"enabled": false,
"retry_count": 2,
"retry_delay_ms": 1000
}
}
}

View File

@@ -38,6 +38,12 @@ public sealed class AttestorOptions
/// </summary>
public TimeSkewOptions TimeSkew { get; set; } = new();
/// <summary>
/// TrustRepo (TUF-based trust distribution) options.
/// Sprint: SPRINT_20260125_002 - PROXY-007
/// </summary>
public TrustRepoIntegrationOptions? TrustRepo { get; set; }
public sealed class SecurityOptions
{
@@ -110,6 +116,59 @@ public sealed class AttestorOptions
public RekorBackendOptions Primary { get; set; } = new();
public RekorMirrorOptions Mirror { get; set; } = new();
/// <summary>
/// Circuit breaker options for resilient Rekor calls.
/// Sprint: SPRINT_20260125_003 - WORKFLOW-006
/// </summary>
public RekorCircuitBreakerOptions CircuitBreaker { get; set; } = new();
}
/// <summary>
/// Circuit breaker configuration for Rekor client.
/// Sprint: SPRINT_20260125_003 - WORKFLOW-006
/// </summary>
public sealed class RekorCircuitBreakerOptions
{
/// <summary>
/// Whether the circuit breaker is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Number of failures before opening the circuit.
/// </summary>
public int FailureThreshold { get; set; } = 5;
/// <summary>
/// Number of successes required to close from half-open state.
/// </summary>
public int SuccessThreshold { get; set; } = 2;
/// <summary>
/// Duration in seconds the circuit stays open.
/// </summary>
public int OpenDurationSeconds { get; set; } = 30;
/// <summary>
/// Time window in seconds for counting failures.
/// </summary>
public int FailureWindowSeconds { get; set; } = 60;
/// <summary>
/// Maximum requests allowed in half-open state.
/// </summary>
public int HalfOpenMaxRequests { get; set; } = 3;
/// <summary>
/// Use cached data when circuit is open.
/// </summary>
public bool UseCacheWhenOpen { get; set; } = true;
/// <summary>
/// Failover to mirror when primary circuit is open.
/// </summary>
public bool FailoverToMirrorWhenOpen { get; set; } = true;
}
public class RekorBackendOptions
@@ -324,4 +383,48 @@ public sealed class AttestorOptions
public IList<string> CertificateChain { get; set; } = new List<string>();
}
/// <summary>
/// TrustRepo integration options for TUF-based trust distribution.
/// Sprint: SPRINT_20260125_002 - PROXY-007
/// </summary>
public sealed class TrustRepoIntegrationOptions
{
/// <summary>
/// Enable TUF-based service map discovery for Rekor endpoints.
/// When enabled, Rekor URLs can be dynamically updated via TUF.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// TUF repository URL for trust metadata.
/// </summary>
public string? TufRepositoryUrl { get; set; }
/// <summary>
/// Local cache path for TUF metadata.
/// </summary>
public string? LocalCachePath { get; set; }
/// <summary>
/// Target name for the Sigstore service map.
/// Default: sigstore-services-v1.json
/// </summary>
public string ServiceMapTarget { get; set; } = "sigstore-services-v1.json";
/// <summary>
/// Environment name for service map overrides.
/// </summary>
public string? Environment { get; set; }
/// <summary>
/// Refresh interval for TUF metadata.
/// </summary>
public int RefreshIntervalMinutes { get; set; } = 60;
/// <summary>
/// Enable offline mode (no network calls).
/// </summary>
public bool OfflineMode { get; set; }
}
}

View File

@@ -0,0 +1,49 @@
// -----------------------------------------------------------------------------
// IRekorBackendResolver.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-007 - Integrate service map with HttpRekorClient
// Description: Interface for resolving Rekor backends with service map support
// -----------------------------------------------------------------------------
namespace StellaOps.Attestor.Core.Rekor;
/// <summary>
/// Resolves Rekor backend configuration from various sources.
/// </summary>
public interface IRekorBackendResolver
{
/// <summary>
/// Resolves the primary Rekor backend.
/// May use TUF service map for dynamic endpoint discovery.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Primary Rekor backend configuration.</returns>
Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the mirror Rekor backend, if configured.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Mirror Rekor backend, or null if not configured.</returns>
Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a named Rekor backend.
/// </summary>
/// <param name="backendName">Backend name (primary, mirror, or custom).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Resolved Rekor backend.</returns>
Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all available backends.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of available backends.</returns>
Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets whether service map-based discovery is available and enabled.
/// </summary>
bool IsServiceMapEnabled { get; }
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// CircuitBreaker.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-005 - Implement circuit breaker for Rekor client
// Description: Circuit breaker implementation for resilient service calls
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace StellaOps.Attestor.Core.Resilience;
/// <summary>
/// Circuit breaker for protecting against cascading failures.
/// </summary>
/// <remarks>
/// State transitions:
/// <code>
/// CLOSED → (failures exceed threshold) → OPEN
/// OPEN → (after timeout) → HALF_OPEN
/// HALF_OPEN → (success threshold met) → CLOSED
/// HALF_OPEN → (failure) → OPEN
/// </code>
/// </remarks>
public sealed class CircuitBreaker : IDisposable
{
private readonly CircuitBreakerOptions _options;
private readonly ILogger<CircuitBreaker>? _logger;
private readonly string _name;
private readonly TimeProvider _timeProvider;
private CircuitState _state = CircuitState.Closed;
private readonly object _stateLock = new();
private readonly ConcurrentQueue<DateTimeOffset> _failureTimestamps = new();
private int _consecutiveSuccesses;
private int _halfOpenRequests;
private DateTimeOffset? _openedAt;
/// <summary>
/// Raised when circuit state changes.
/// </summary>
public event Action<CircuitState, CircuitState>? StateChanged;
/// <summary>
/// Creates a new circuit breaker.
/// </summary>
public CircuitBreaker(
string name,
CircuitBreakerOptions options,
ILogger<CircuitBreaker>? logger = null,
TimeProvider? timeProvider = null)
{
_name = name ?? throw new ArgumentNullException(nameof(name));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Gets the current circuit state.
/// </summary>
public CircuitState State
{
get
{
lock (_stateLock)
{
// Check if we should transition from Open to HalfOpen
if (_state == CircuitState.Open && ShouldTransitionToHalfOpen())
{
TransitionTo(CircuitState.HalfOpen);
}
return _state;
}
}
}
/// <summary>
/// Gets the circuit breaker name.
/// </summary>
public string Name => _name;
/// <summary>
/// Checks if a request is allowed through the circuit.
/// </summary>
/// <returns>True if request can proceed, false if circuit is open.</returns>
public bool AllowRequest()
{
if (!_options.Enabled)
{
return true;
}
lock (_stateLock)
{
var currentState = State; // This may trigger Open→HalfOpen transition
switch (currentState)
{
case CircuitState.Closed:
return true;
case CircuitState.Open:
_logger?.LogDebug(
"Circuit {Name} is OPEN, rejecting request",
_name);
return false;
case CircuitState.HalfOpen:
if (_halfOpenRequests < _options.HalfOpenMaxRequests)
{
_halfOpenRequests++;
_logger?.LogDebug(
"Circuit {Name} is HALF-OPEN, allowing probe request ({Count}/{Max})",
_name, _halfOpenRequests, _options.HalfOpenMaxRequests);
return true;
}
_logger?.LogDebug(
"Circuit {Name} is HALF-OPEN but max probes reached, rejecting request",
_name);
return false;
default:
return true;
}
}
}
/// <summary>
/// Records a successful request.
/// </summary>
public void RecordSuccess()
{
if (!_options.Enabled)
{
return;
}
lock (_stateLock)
{
switch (_state)
{
case CircuitState.Closed:
// Clear failure history on success
while (_failureTimestamps.TryDequeue(out _)) { }
break;
case CircuitState.HalfOpen:
_consecutiveSuccesses++;
_logger?.LogDebug(
"Circuit {Name} recorded success in HALF-OPEN ({Count}/{Threshold})",
_name, _consecutiveSuccesses, _options.SuccessThreshold);
if (_consecutiveSuccesses >= _options.SuccessThreshold)
{
TransitionTo(CircuitState.Closed);
}
break;
}
}
}
/// <summary>
/// Records a failed request.
/// </summary>
public void RecordFailure()
{
if (!_options.Enabled)
{
return;
}
lock (_stateLock)
{
var now = _timeProvider.GetUtcNow();
switch (_state)
{
case CircuitState.Closed:
_failureTimestamps.Enqueue(now);
CleanupOldFailures(now);
var failureCount = _failureTimestamps.Count;
_logger?.LogDebug(
"Circuit {Name} recorded failure ({Count}/{Threshold})",
_name, failureCount, _options.FailureThreshold);
if (failureCount >= _options.FailureThreshold)
{
TransitionTo(CircuitState.Open);
}
break;
case CircuitState.HalfOpen:
_logger?.LogDebug(
"Circuit {Name} recorded failure in HALF-OPEN, reopening",
_name);
TransitionTo(CircuitState.Open);
break;
}
}
}
/// <summary>
/// Executes an action with circuit breaker protection.
/// </summary>
public async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> action,
Func<CancellationToken, Task<T>>? fallback = null,
CancellationToken cancellationToken = default)
{
if (!AllowRequest())
{
if (fallback != null)
{
_logger?.LogDebug("Circuit {Name} using fallback", _name);
return await fallback(cancellationToken);
}
throw new CircuitBreakerOpenException(_name, _state);
}
try
{
var result = await action(cancellationToken);
RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
RecordFailure();
if (fallback != null && _state == CircuitState.Open)
{
_logger?.LogDebug(ex, "Circuit {Name} action failed, using fallback", _name);
return await fallback(cancellationToken);
}
throw;
}
}
/// <summary>
/// Executes an action with circuit breaker protection.
/// </summary>
public async Task ExecuteAsync(
Func<CancellationToken, Task> action,
Func<CancellationToken, Task>? fallback = null,
CancellationToken cancellationToken = default)
{
await ExecuteAsync(
async ct =>
{
await action(ct);
return true;
},
fallback != null
? async ct =>
{
await fallback(ct);
return true;
}
: null,
cancellationToken);
}
/// <summary>
/// Manually resets the circuit to closed state.
/// </summary>
public void Reset()
{
lock (_stateLock)
{
TransitionTo(CircuitState.Closed);
while (_failureTimestamps.TryDequeue(out _)) { }
}
}
private void TransitionTo(CircuitState newState)
{
var oldState = _state;
if (oldState == newState)
{
return;
}
_state = newState;
switch (newState)
{
case CircuitState.Closed:
_consecutiveSuccesses = 0;
_halfOpenRequests = 0;
_openedAt = null;
while (_failureTimestamps.TryDequeue(out _)) { }
break;
case CircuitState.Open:
_openedAt = _timeProvider.GetUtcNow();
_consecutiveSuccesses = 0;
_halfOpenRequests = 0;
break;
case CircuitState.HalfOpen:
_consecutiveSuccesses = 0;
_halfOpenRequests = 0;
break;
}
_logger?.LogInformation(
"Circuit {Name} transitioned from {OldState} to {NewState}",
_name, oldState, newState);
StateChanged?.Invoke(oldState, newState);
}
private bool ShouldTransitionToHalfOpen()
{
if (_state != CircuitState.Open || !_openedAt.HasValue)
{
return false;
}
var elapsed = _timeProvider.GetUtcNow() - _openedAt.Value;
return elapsed.TotalSeconds >= _options.OpenDurationSeconds;
}
private void CleanupOldFailures(DateTimeOffset now)
{
var cutoff = now.AddSeconds(-_options.FailureWindowSeconds);
while (_failureTimestamps.TryPeek(out var oldest) && oldest < cutoff)
{
_failureTimestamps.TryDequeue(out _);
}
}
private static bool IsTransientException(Exception ex)
{
return ex is HttpRequestException
or TaskCanceledException
or TimeoutException
or OperationCanceledException;
}
public void Dispose()
{
// Nothing to dispose, but implement for future resource cleanup
}
}
/// <summary>
/// Exception thrown when circuit breaker is open.
/// </summary>
public sealed class CircuitBreakerOpenException : Exception
{
public string CircuitName { get; }
public CircuitState State { get; }
public CircuitBreakerOpenException(string circuitName, CircuitState state)
: base($"Circuit breaker '{circuitName}' is {state}, request rejected")
{
CircuitName = circuitName;
State = state;
}
}

View File

@@ -0,0 +1,76 @@
// -----------------------------------------------------------------------------
// CircuitBreakerOptions.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-005 - Implement circuit breaker for Rekor client
// Description: Configuration options for circuit breaker pattern
// -----------------------------------------------------------------------------
namespace StellaOps.Attestor.Core.Resilience;
/// <summary>
/// Configuration options for the circuit breaker pattern.
/// </summary>
public sealed record CircuitBreakerOptions
{
/// <summary>
/// Whether the circuit breaker is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Number of consecutive failures before opening the circuit.
/// </summary>
public int FailureThreshold { get; init; } = 5;
/// <summary>
/// Number of successful requests required to close the circuit from half-open state.
/// </summary>
public int SuccessThreshold { get; init; } = 2;
/// <summary>
/// Duration in seconds the circuit stays open before transitioning to half-open.
/// </summary>
public int OpenDurationSeconds { get; init; } = 30;
/// <summary>
/// Time window in seconds for counting failures.
/// Failures outside this window are not counted.
/// </summary>
public int FailureWindowSeconds { get; init; } = 60;
/// <summary>
/// Maximum number of requests allowed through in half-open state.
/// </summary>
public int HalfOpenMaxRequests { get; init; } = 3;
/// <summary>
/// Whether to use cached data when circuit is open.
/// </summary>
public bool UseCacheWhenOpen { get; init; } = true;
/// <summary>
/// Whether to attempt failover to mirror when circuit is open.
/// </summary>
public bool FailoverToMirrorWhenOpen { get; init; } = true;
}
/// <summary>
/// Circuit breaker state.
/// </summary>
public enum CircuitState
{
/// <summary>
/// Circuit is closed, requests flow normally.
/// </summary>
Closed,
/// <summary>
/// Circuit is open, requests fail fast.
/// </summary>
Open,
/// <summary>
/// Circuit is testing if backend has recovered.
/// </summary>
HalfOpen
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// ResilientRekorClient.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-006 - Implement mirror failover
// Description: Resilient Rekor client with circuit breaker and mirror failover
// -----------------------------------------------------------------------------
using System;
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.Resilience;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Rekor;
/// <summary>
/// Resilient Rekor client with circuit breaker and automatic mirror failover.
/// </summary>
/// <remarks>
/// Flow:
/// 1. Try primary backend
/// 2. If primary circuit is OPEN and mirror is enabled, try mirror
/// 3. If primary fails and circuit is HALF_OPEN, mark failure and try mirror
/// 4. Track success/failure for circuit breaker state transitions
/// </remarks>
public sealed class ResilientRekorClient : IRekorClient, IDisposable
{
private readonly IRekorClient _innerClient;
private readonly IRekorBackendResolver _backendResolver;
private readonly CircuitBreaker _primaryCircuitBreaker;
private readonly CircuitBreaker? _mirrorCircuitBreaker;
private readonly AttestorOptions _options;
private readonly ILogger<ResilientRekorClient> _logger;
public ResilientRekorClient(
IRekorClient innerClient,
IRekorBackendResolver backendResolver,
IOptions<AttestorOptions> options,
ILogger<ResilientRekorClient> logger,
TimeProvider? timeProvider = null)
{
_innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient));
_backendResolver = backendResolver ?? throw new ArgumentNullException(nameof(backendResolver));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var cbOptions = MapCircuitBreakerOptions(_options.Rekor.CircuitBreaker);
var time = timeProvider ?? TimeProvider.System;
_primaryCircuitBreaker = new CircuitBreaker(
"rekor-primary",
cbOptions,
logger as ILogger<CircuitBreaker>,
time);
_primaryCircuitBreaker.StateChanged += OnPrimaryCircuitStateChanged;
// Create mirror circuit breaker if mirror is enabled
if (_options.Rekor.Mirror.Enabled)
{
_mirrorCircuitBreaker = new CircuitBreaker(
"rekor-mirror",
cbOptions,
logger as ILogger<CircuitBreaker>,
time);
_mirrorCircuitBreaker.StateChanged += OnMirrorCircuitStateChanged;
}
}
/// <summary>
/// Gets the current state of the primary circuit breaker.
/// </summary>
public CircuitState PrimaryCircuitState => _primaryCircuitBreaker.State;
/// <summary>
/// Gets the current state of the mirror circuit breaker.
/// </summary>
public CircuitState? MirrorCircuitState => _mirrorCircuitBreaker?.State;
/// <summary>
/// Gets whether requests are currently being routed to the mirror.
/// </summary>
public bool IsUsingMirror => _options.Rekor.Mirror.Enabled
&& _options.Rekor.CircuitBreaker.FailoverToMirrorWhenOpen
&& _primaryCircuitBreaker.State == CircuitState.Open
&& _mirrorCircuitBreaker?.State != CircuitState.Open;
/// <summary>
/// Raised when failover to mirror occurs.
/// </summary>
public event Action<string>? FailoverOccurred;
/// <summary>
/// Raised when failback to primary occurs.
/// </summary>
public event Action<string>? FailbackOccurred;
public async Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
// Submissions always go to primary (or resolved backend)
// We don't submit to mirrors to avoid duplicates
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.SubmitAsync(request, b, ct),
backend,
"Submit",
allowMirror: false, // Never submit to mirror
cancellationToken);
}
public async Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.GetProofAsync(rekorUuid, b, ct),
backend,
"GetProof",
allowMirror: true,
cancellationToken);
}
public async Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
string rekorUuid,
byte[] payloadDigest,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.VerifyInclusionAsync(rekorUuid, payloadDigest, b, ct),
backend,
"VerifyInclusion",
allowMirror: true,
cancellationToken);
}
private async Task<T> ExecuteWithResilienceAsync<T>(
Func<RekorBackend, CancellationToken, Task<T>> operation,
RekorBackend requestedBackend,
string operationName,
bool allowMirror,
CancellationToken cancellationToken)
{
var cbOptions = _options.Rekor.CircuitBreaker;
// If circuit breaker is disabled, just execute directly
if (!cbOptions.Enabled)
{
return await operation(requestedBackend, cancellationToken);
}
// Check if we should use mirror due to primary circuit being open
if (allowMirror && ShouldUseMirror())
{
_logger.LogDebug(
"Primary circuit is OPEN, routing {Operation} to mirror",
operationName);
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker!.AllowRequest())
{
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_mirrorCircuitBreaker.RecordFailure();
_logger.LogWarning(ex,
"Mirror {Operation} failed, no fallback available",
operationName);
throw;
}
}
}
// Try primary
if (_primaryCircuitBreaker.AllowRequest())
{
try
{
var result = await operation(requestedBackend, cancellationToken);
_primaryCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_primaryCircuitBreaker.RecordFailure();
// Try mirror on primary failure (if allowed and available)
if (allowMirror && cbOptions.FailoverToMirrorWhenOpen)
{
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker?.AllowRequest() == true)
{
_logger.LogWarning(ex,
"Primary {Operation} failed, failing over to mirror",
operationName);
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
OnFailover("immediate-failover");
return result;
}
catch (Exception mirrorEx) when (IsTransientException(mirrorEx))
{
_mirrorCircuitBreaker.RecordFailure();
_logger.LogWarning(mirrorEx,
"Mirror {Operation} also failed",
operationName);
}
}
}
throw;
}
}
// Primary circuit is open, check for mirror
if (allowMirror && cbOptions.FailoverToMirrorWhenOpen)
{
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker?.AllowRequest() == true)
{
_logger.LogDebug(
"Primary circuit OPEN, using mirror for {Operation}",
operationName);
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_mirrorCircuitBreaker.RecordFailure();
throw;
}
}
}
throw new CircuitBreakerOpenException(
_primaryCircuitBreaker.Name,
_primaryCircuitBreaker.State);
}
private bool ShouldUseMirror()
{
return _options.Rekor.Mirror.Enabled
&& _options.Rekor.CircuitBreaker.FailoverToMirrorWhenOpen
&& _primaryCircuitBreaker.State == CircuitState.Open
&& _mirrorCircuitBreaker?.State != CircuitState.Open;
}
private async Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken)
{
if (!_options.Rekor.Mirror.Enabled)
{
return null;
}
return await _backendResolver.GetMirrorBackendAsync(cancellationToken);
}
private void OnPrimaryCircuitStateChanged(CircuitState oldState, CircuitState newState)
{
_logger.LogInformation(
"Primary Rekor circuit breaker: {OldState} -> {NewState}",
oldState, newState);
if (newState == CircuitState.Open && _options.Rekor.Mirror.Enabled)
{
OnFailover("circuit-open");
}
else if (oldState == CircuitState.Open && newState == CircuitState.Closed)
{
OnFailback("circuit-closed");
}
}
private void OnMirrorCircuitStateChanged(CircuitState oldState, CircuitState newState)
{
_logger.LogInformation(
"Mirror Rekor circuit breaker: {OldState} -> {NewState}",
oldState, newState);
}
private void OnFailover(string reason)
{
_logger.LogWarning(
"Rekor failover to mirror activated: {Reason}",
reason);
FailoverOccurred?.Invoke(reason);
}
private void OnFailback(string reason)
{
_logger.LogInformation(
"Rekor failback to primary activated: {Reason}",
reason);
FailbackOccurred?.Invoke(reason);
}
private static CircuitBreakerOptions MapCircuitBreakerOptions(
AttestorOptions.RekorCircuitBreakerOptions options)
{
return new CircuitBreakerOptions
{
Enabled = options.Enabled,
FailureThreshold = options.FailureThreshold,
SuccessThreshold = options.SuccessThreshold,
OpenDurationSeconds = options.OpenDurationSeconds,
FailureWindowSeconds = options.FailureWindowSeconds,
HalfOpenMaxRequests = options.HalfOpenMaxRequests,
UseCacheWhenOpen = options.UseCacheWhenOpen,
FailoverToMirrorWhenOpen = options.FailoverToMirrorWhenOpen
};
}
private static bool IsTransientException(Exception ex)
{
return ex is HttpRequestException
or TaskCanceledException
or TimeoutException
or OperationCanceledException;
}
/// <summary>
/// Resets both circuit breakers to closed state.
/// </summary>
public void Reset()
{
_primaryCircuitBreaker.Reset();
_mirrorCircuitBreaker?.Reset();
}
public void Dispose()
{
_primaryCircuitBreaker.StateChanged -= OnPrimaryCircuitStateChanged;
_primaryCircuitBreaker.Dispose();
if (_mirrorCircuitBreaker != null)
{
_mirrorCircuitBreaker.StateChanged -= OnMirrorCircuitStateChanged;
_mirrorCircuitBreaker.Dispose();
}
}
}

View File

@@ -0,0 +1,285 @@
// -----------------------------------------------------------------------------
// ServiceMapAwareRekorBackendResolver.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-007 - Integrate service map with HttpRekorClient
// Description: Resolves Rekor backends using TUF service map with configuration fallback
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.TrustRepo;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.Infrastructure.Rekor;
/// <summary>
/// Resolves Rekor backends using TUF service map for dynamic endpoint discovery,
/// with fallback to static configuration when service map is unavailable.
/// </summary>
internal sealed class ServiceMapAwareRekorBackendResolver : IRekorBackendResolver
{
private readonly ISigstoreServiceMapLoader _serviceMapLoader;
private readonly IOptions<AttestorOptions> _options;
private readonly ILogger<ServiceMapAwareRekorBackendResolver> _logger;
private readonly bool _serviceMapEnabled;
// Cached backend from service map
private RekorBackend? _cachedServiceMapBackend;
private DateTimeOffset? _cachedAt;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
private readonly SemaphoreSlim _cacheLock = new(1, 1);
public ServiceMapAwareRekorBackendResolver(
ISigstoreServiceMapLoader serviceMapLoader,
IOptions<AttestorOptions> options,
ILogger<ServiceMapAwareRekorBackendResolver> logger)
{
_serviceMapLoader = serviceMapLoader ?? throw new ArgumentNullException(nameof(serviceMapLoader));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Service map is enabled if TrustRepo is configured
_serviceMapEnabled = options.Value.TrustRepo?.Enabled ?? false;
}
/// <inheritdoc />
public bool IsServiceMapEnabled => _serviceMapEnabled;
/// <inheritdoc />
public async Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default)
{
// Try service map first if enabled
if (_serviceMapEnabled)
{
var serviceMapBackend = await TryGetServiceMapBackendAsync(cancellationToken);
if (serviceMapBackend != null)
{
_logger.LogDebug("Using Rekor backend from TUF service map: {Url}", serviceMapBackend.Url);
return serviceMapBackend;
}
_logger.LogDebug("Service map unavailable, falling back to configuration");
}
// Fallback to configuration
return RekorBackendResolver.ResolveBackend(_options.Value, "primary", allowFallbackToPrimary: true);
}
/// <inheritdoc />
public Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
if (!opts.Rekor.Mirror.Enabled || string.IsNullOrWhiteSpace(opts.Rekor.Mirror.Url))
{
return Task.FromResult<RekorBackend?>(null);
}
var mirror = RekorBackendResolver.ResolveBackend(opts, "mirror", allowFallbackToPrimary: false);
return Task.FromResult<RekorBackend?>(mirror);
}
/// <inheritdoc />
public async Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default)
{
var normalized = string.IsNullOrWhiteSpace(backendName)
? "primary"
: backendName.Trim().ToLowerInvariant();
if (normalized == "primary")
{
return await GetPrimaryBackendAsync(cancellationToken);
}
if (normalized == "mirror")
{
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror == null)
{
throw new InvalidOperationException("Mirror backend is not configured");
}
return mirror;
}
// Unknown backend name - try configuration fallback
return RekorBackendResolver.ResolveBackend(_options.Value, backendName, allowFallbackToPrimary: true);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default)
{
var backends = new List<RekorBackend>();
// Add primary
backends.Add(await GetPrimaryBackendAsync(cancellationToken));
// Add mirror if configured
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror != null)
{
backends.Add(mirror);
}
return backends;
}
/// <summary>
/// Attempts to get Rekor backend from TUF service map.
/// </summary>
private async Task<RekorBackend?> TryGetServiceMapBackendAsync(CancellationToken cancellationToken)
{
// Check cache first
if (_cachedServiceMapBackend != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _cacheDuration)
{
return _cachedServiceMapBackend;
}
}
await _cacheLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
if (_cachedServiceMapBackend != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _cacheDuration)
{
return _cachedServiceMapBackend;
}
}
return await LoadFromServiceMapAsync(cancellationToken);
}
finally
{
_cacheLock.Release();
}
}
/// <summary>
/// Loads Rekor backend from service map.
/// </summary>
private async Task<RekorBackend?> LoadFromServiceMapAsync(CancellationToken cancellationToken)
{
try
{
var serviceMap = await _serviceMapLoader.GetServiceMapAsync(cancellationToken);
if (serviceMap?.Rekor == null || string.IsNullOrEmpty(serviceMap.Rekor.Url))
{
_logger.LogDebug("Service map does not contain Rekor configuration");
return null;
}
var rekor = serviceMap.Rekor;
var opts = _options.Value;
// Build backend from service map, using config for non-mapped settings
var backend = new RekorBackend
{
Name = "primary-servicemap",
Url = new Uri(rekor.Url, UriKind.Absolute),
Version = ParseLogVersion(opts.Rekor.Primary.Version),
TileBaseUrl = !string.IsNullOrEmpty(rekor.TileBaseUrl)
? new Uri(rekor.TileBaseUrl, UriKind.Absolute)
: null,
LogId = !string.IsNullOrEmpty(rekor.LogId)
? rekor.LogId
: opts.Rekor.Primary.LogId,
ProofTimeout = TimeSpan.FromMilliseconds(opts.Rekor.Primary.ProofTimeoutMs),
PollInterval = TimeSpan.FromMilliseconds(opts.Rekor.Primary.PollIntervalMs),
MaxAttempts = opts.Rekor.Primary.MaxAttempts
};
_cachedServiceMapBackend = backend;
_cachedAt = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Loaded Rekor endpoint from TUF service map v{Version}: {Url}",
serviceMap.Version,
backend.Url);
return backend;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Rekor backend from service map");
return null;
}
}
/// <summary>
/// Parses the log version string to the enum value.
/// </summary>
private static RekorLogVersion ParseLogVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return RekorLogVersion.Auto;
}
return version.Trim().ToUpperInvariant() switch
{
"AUTO" => RekorLogVersion.Auto,
"V2" or "2" => RekorLogVersion.V2,
_ => RekorLogVersion.Auto
};
}
}
/// <summary>
/// Simple resolver that uses only static configuration (no service map).
/// </summary>
internal sealed class ConfiguredRekorBackendResolver : IRekorBackendResolver
{
private readonly IOptions<AttestorOptions> _options;
public ConfiguredRekorBackendResolver(IOptions<AttestorOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool IsServiceMapEnabled => false;
public Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(RekorBackendResolver.ResolveBackend(_options.Value, "primary", true));
}
public Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
if (!opts.Rekor.Mirror.Enabled || string.IsNullOrWhiteSpace(opts.Rekor.Mirror.Url))
{
return Task.FromResult<RekorBackend?>(null);
}
var mirror = RekorBackendResolver.ResolveBackend(opts, "mirror", false);
return Task.FromResult<RekorBackend?>(mirror);
}
public Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default)
{
return Task.FromResult(RekorBackendResolver.ResolveBackend(_options.Value, backendName, true));
}
public async Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default)
{
var backends = new List<RekorBackend>
{
await GetPrimaryBackendAsync(cancellationToken)
};
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror != null)
{
backends.Add(mirror);
}
return backends;
}
}

View File

@@ -30,6 +30,7 @@ using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.InToto.Layout;
using StellaOps.Attestor.Infrastructure.InToto;
using StellaOps.Attestor.Verify;
using StellaOps.Attestor.TrustRepo;
using StellaOps.Determinism;
namespace StellaOps.Attestor.Infrastructure;
@@ -96,6 +97,27 @@ public static class ServiceCollectionExtensions
});
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
// Register Rekor backend resolver with service map support
// Sprint: SPRINT_20260125_002 - PROXY-007
services.AddSingleton<IRekorBackendResolver>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
// If TrustRepo integration is enabled, use service map-aware resolver
if (options.TrustRepo?.Enabled == true)
{
var serviceMapLoader = sp.GetRequiredService<ISigstoreServiceMapLoader>();
var logger = sp.GetRequiredService<ILogger<ServiceMapAwareRekorBackendResolver>>();
return new ServiceMapAwareRekorBackendResolver(
serviceMapLoader,
sp.GetRequiredService<IOptions<AttestorOptions>>(),
logger);
}
// Otherwise, use static configuration resolver
return new ConfiguredRekorBackendResolver(sp.GetRequiredService<IOptions<AttestorOptions>>());
});
// Rekor v2 tile-based client for Sunlight/tile log format
services.AddHttpClient<HttpRekorTileClient>((sp, client) =>
{

View File

@@ -15,6 +15,7 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.TrustRepo\StellaOps.Attestor.TrustRepo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />

View File

@@ -0,0 +1,188 @@
// -----------------------------------------------------------------------------
// ITufClient.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: TUF client interface for trust metadata management
// -----------------------------------------------------------------------------
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Client for fetching and validating TUF metadata.
/// Implements the TUF 1.0 client workflow for secure trust distribution.
/// </summary>
public interface ITufClient
{
/// <summary>
/// Gets the current trust state.
/// </summary>
TufTrustState TrustState { get; }
/// <summary>
/// Refreshes TUF metadata from the repository.
/// Follows the TUF client workflow: timestamp -> snapshot -> targets -> root (if needed).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success and any warnings.</returns>
Task<TufRefreshResult> RefreshAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets a target file by name.
/// </summary>
/// <param name="targetName">Target name (e.g., "rekor-key-v1").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Target content, or null if not found.</returns>
Task<TufTargetResult?> GetTargetAsync(string targetName, CancellationToken cancellationToken = default);
/// <summary>
/// Gets multiple target files.
/// </summary>
/// <param name="targetNames">Target names.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of target name to content.</returns>
Task<IReadOnlyDictionary<string, TufTargetResult>> GetTargetsAsync(
IEnumerable<string> targetNames,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if TUF metadata is fresh (within configured threshold).
/// </summary>
/// <returns>True if metadata is fresh, false if stale.</returns>
bool IsMetadataFresh();
/// <summary>
/// Gets the age of the current metadata.
/// </summary>
/// <returns>Time since last refresh, or null if never refreshed.</returns>
TimeSpan? GetMetadataAge();
}
/// <summary>
/// Current TUF trust state.
/// </summary>
public sealed record TufTrustState
{
/// <summary>
/// Current root metadata.
/// </summary>
public TufSigned<TufRoot>? Root { get; init; }
/// <summary>
/// Current snapshot metadata.
/// </summary>
public TufSigned<TufSnapshot>? Snapshot { get; init; }
/// <summary>
/// Current timestamp metadata.
/// </summary>
public TufSigned<TufTimestamp>? Timestamp { get; init; }
/// <summary>
/// Current targets metadata.
/// </summary>
public TufSigned<TufTargets>? Targets { get; init; }
/// <summary>
/// Timestamp of last successful refresh.
/// </summary>
public DateTimeOffset? LastRefreshed { get; init; }
/// <summary>
/// Whether trust state is initialized.
/// </summary>
public bool IsInitialized => Root != null && Timestamp != null;
}
/// <summary>
/// Result of TUF metadata refresh.
/// </summary>
public sealed record TufRefreshResult
{
/// <summary>
/// Whether refresh was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Error message if refresh failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Warnings encountered during refresh.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Whether root was updated.
/// </summary>
public bool RootUpdated { get; init; }
/// <summary>
/// Whether targets were updated.
/// </summary>
public bool TargetsUpdated { get; init; }
/// <summary>
/// New root version (if updated).
/// </summary>
public int? NewRootVersion { get; init; }
/// <summary>
/// New targets version (if updated).
/// </summary>
public int? NewTargetsVersion { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static TufRefreshResult Succeeded(
bool rootUpdated = false,
bool targetsUpdated = false,
int? newRootVersion = null,
int? newTargetsVersion = null,
IReadOnlyList<string>? warnings = null)
=> new()
{
Success = true,
RootUpdated = rootUpdated,
TargetsUpdated = targetsUpdated,
NewRootVersion = newRootVersion,
NewTargetsVersion = newTargetsVersion,
Warnings = warnings ?? []
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static TufRefreshResult Failed(string error)
=> new() { Success = false, Error = error };
}
/// <summary>
/// Result of fetching a TUF target.
/// </summary>
public sealed record TufTargetResult
{
/// <summary>
/// Target name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Target content bytes.
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// Target info from metadata.
/// </summary>
public required TufTargetInfo Info { get; init; }
/// <summary>
/// Whether target was fetched from cache.
/// </summary>
public bool FromCache { get; init; }
}

View File

@@ -0,0 +1,185 @@
// -----------------------------------------------------------------------------
// SigstoreServiceMap.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-003 - Create service map loader
// Description: Sigstore service discovery map model
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.TrustRepo.Models;
/// <summary>
/// Service discovery map for Sigstore infrastructure endpoints.
/// Distributed via TUF for dynamic endpoint management.
/// </summary>
public sealed record SigstoreServiceMap
{
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
/// <summary>
/// Rekor transparency log configuration.
/// </summary>
[JsonPropertyName("rekor")]
public RekorServiceConfig Rekor { get; init; } = new();
/// <summary>
/// Fulcio certificate authority configuration.
/// </summary>
[JsonPropertyName("fulcio")]
public FulcioServiceConfig? Fulcio { get; init; }
/// <summary>
/// Certificate Transparency log configuration.
/// </summary>
[JsonPropertyName("ct_log")]
public CtLogServiceConfig? CtLog { get; init; }
/// <summary>
/// Timestamp authority configuration.
/// </summary>
[JsonPropertyName("timestamp_authority")]
public TsaServiceConfig? TimestampAuthority { get; init; }
/// <summary>
/// Site-local endpoint overrides by environment name.
/// </summary>
[JsonPropertyName("overrides")]
public Dictionary<string, ServiceOverrides>? Overrides { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
public ServiceMapMetadata? Metadata { get; init; }
}
/// <summary>
/// Rekor service configuration.
/// </summary>
public sealed record RekorServiceConfig
{
/// <summary>
/// Primary Rekor API endpoint.
/// </summary>
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
/// <summary>
/// Optional tile endpoint (defaults to {url}/tile/).
/// </summary>
[JsonPropertyName("tile_base_url")]
public string? TileBaseUrl { get; init; }
/// <summary>
/// SHA-256 hash of log public key (hex-encoded).
/// </summary>
[JsonPropertyName("log_id")]
public string? LogId { get; init; }
/// <summary>
/// TUF target name for Rekor public key.
/// </summary>
[JsonPropertyName("public_key_target")]
public string? PublicKeyTarget { get; init; }
}
/// <summary>
/// Fulcio service configuration.
/// </summary>
public sealed record FulcioServiceConfig
{
/// <summary>
/// Fulcio API endpoint.
/// </summary>
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
/// <summary>
/// TUF target name for Fulcio root certificate.
/// </summary>
[JsonPropertyName("root_cert_target")]
public string? RootCertTarget { get; init; }
}
/// <summary>
/// Certificate Transparency log configuration.
/// </summary>
public sealed record CtLogServiceConfig
{
/// <summary>
/// CT log API endpoint.
/// </summary>
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
/// <summary>
/// TUF target name for CT log public key.
/// </summary>
[JsonPropertyName("public_key_target")]
public string? PublicKeyTarget { get; init; }
}
/// <summary>
/// Timestamp authority configuration.
/// </summary>
public sealed record TsaServiceConfig
{
/// <summary>
/// TSA endpoint.
/// </summary>
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
/// <summary>
/// TUF target name for TSA certificate chain.
/// </summary>
[JsonPropertyName("cert_chain_target")]
public string? CertChainTarget { get; init; }
}
/// <summary>
/// Site-local endpoint overrides.
/// </summary>
public sealed record ServiceOverrides
{
/// <summary>
/// Override Rekor URL for this environment.
/// </summary>
[JsonPropertyName("rekor_url")]
public string? RekorUrl { get; init; }
/// <summary>
/// Override Fulcio URL for this environment.
/// </summary>
[JsonPropertyName("fulcio_url")]
public string? FulcioUrl { get; init; }
/// <summary>
/// Override CT log URL for this environment.
/// </summary>
[JsonPropertyName("ct_log_url")]
public string? CtLogUrl { get; init; }
}
/// <summary>
/// Service map metadata.
/// </summary>
public sealed record ServiceMapMetadata
{
/// <summary>
/// Last update timestamp.
/// </summary>
[JsonPropertyName("updated_at")]
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>
/// Human-readable note about this configuration.
/// </summary>
[JsonPropertyName("note")]
public string? Note { get; init; }
}

View File

@@ -0,0 +1,231 @@
// -----------------------------------------------------------------------------
// TufModels.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: TUF metadata models per TUF 1.0 specification
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.TrustRepo.Models;
/// <summary>
/// TUF root metadata - the trust anchor.
/// Contains keys and thresholds for all roles.
/// </summary>
public sealed record TufRoot
{
[JsonPropertyName("_type")]
public string Type { get; init; } = "root";
[JsonPropertyName("spec_version")]
public string SpecVersion { get; init; } = "1.0.0";
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("expires")]
public DateTimeOffset Expires { get; init; }
[JsonPropertyName("keys")]
public Dictionary<string, TufKey> Keys { get; init; } = new();
[JsonPropertyName("roles")]
public Dictionary<string, TufRoleDefinition> Roles { get; init; } = new();
[JsonPropertyName("consistent_snapshot")]
public bool ConsistentSnapshot { get; init; }
}
/// <summary>
/// TUF snapshot metadata - versions of all metadata files.
/// </summary>
public sealed record TufSnapshot
{
[JsonPropertyName("_type")]
public string Type { get; init; } = "snapshot";
[JsonPropertyName("spec_version")]
public string SpecVersion { get; init; } = "1.0.0";
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("expires")]
public DateTimeOffset Expires { get; init; }
[JsonPropertyName("meta")]
public Dictionary<string, TufMetaFile> Meta { get; init; } = new();
}
/// <summary>
/// TUF timestamp metadata - freshness indicator.
/// </summary>
public sealed record TufTimestamp
{
[JsonPropertyName("_type")]
public string Type { get; init; } = "timestamp";
[JsonPropertyName("spec_version")]
public string SpecVersion { get; init; } = "1.0.0";
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("expires")]
public DateTimeOffset Expires { get; init; }
[JsonPropertyName("meta")]
public Dictionary<string, TufMetaFile> Meta { get; init; } = new();
}
/// <summary>
/// TUF targets metadata - describes available targets.
/// </summary>
public sealed record TufTargets
{
[JsonPropertyName("_type")]
public string Type { get; init; } = "targets";
[JsonPropertyName("spec_version")]
public string SpecVersion { get; init; } = "1.0.0";
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("expires")]
public DateTimeOffset Expires { get; init; }
[JsonPropertyName("targets")]
public Dictionary<string, TufTargetInfo> Targets { get; init; } = new();
[JsonPropertyName("delegations")]
public TufDelegations? Delegations { get; init; }
}
/// <summary>
/// TUF key definition.
/// </summary>
public sealed record TufKey
{
[JsonPropertyName("keytype")]
public string KeyType { get; init; } = string.Empty;
[JsonPropertyName("scheme")]
public string Scheme { get; init; } = string.Empty;
[JsonPropertyName("keyval")]
public TufKeyValue KeyVal { get; init; } = new();
}
/// <summary>
/// TUF key value (public key material).
/// </summary>
public sealed record TufKeyValue
{
[JsonPropertyName("public")]
public string Public { get; init; } = string.Empty;
}
/// <summary>
/// TUF role definition with keys and threshold.
/// </summary>
public sealed record TufRoleDefinition
{
[JsonPropertyName("keyids")]
public List<string> KeyIds { get; init; } = new();
[JsonPropertyName("threshold")]
public int Threshold { get; init; }
}
/// <summary>
/// TUF metadata file reference.
/// </summary>
public sealed record TufMetaFile
{
[JsonPropertyName("version")]
public int Version { get; init; }
[JsonPropertyName("length")]
public long? Length { get; init; }
[JsonPropertyName("hashes")]
public Dictionary<string, string>? Hashes { get; init; }
}
/// <summary>
/// TUF target file information.
/// </summary>
public sealed record TufTargetInfo
{
[JsonPropertyName("length")]
public long Length { get; init; }
[JsonPropertyName("hashes")]
public Dictionary<string, string> Hashes { get; init; } = new();
[JsonPropertyName("custom")]
public Dictionary<string, object>? Custom { get; init; }
}
/// <summary>
/// TUF delegations for target roles.
/// </summary>
public sealed record TufDelegations
{
[JsonPropertyName("keys")]
public Dictionary<string, TufKey> Keys { get; init; } = new();
[JsonPropertyName("roles")]
public List<TufDelegatedRole> Roles { get; init; } = new();
}
/// <summary>
/// TUF delegated role definition.
/// </summary>
public sealed record TufDelegatedRole
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("keyids")]
public List<string> KeyIds { get; init; } = new();
[JsonPropertyName("threshold")]
public int Threshold { get; init; }
[JsonPropertyName("terminating")]
public bool Terminating { get; init; }
[JsonPropertyName("paths")]
public List<string>? Paths { get; init; }
[JsonPropertyName("path_hash_prefixes")]
public List<string>? PathHashPrefixes { get; init; }
}
/// <summary>
/// Signed TUF metadata envelope.
/// </summary>
/// <typeparam name="T">The metadata type (Root, Snapshot, etc.)</typeparam>
public sealed record TufSigned<T> where T : class
{
[JsonPropertyName("signed")]
public T Signed { get; init; } = null!;
[JsonPropertyName("signatures")]
public List<TufSignature> Signatures { get; init; } = new();
}
/// <summary>
/// TUF signature.
/// </summary>
public sealed record TufSignature
{
[JsonPropertyName("keyid")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("sig")]
public string Sig { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,329 @@
// -----------------------------------------------------------------------------
// SigstoreServiceMapLoader.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-003 - Create service map loader
// Description: Loads Sigstore service map from TUF repository
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Interface for loading Sigstore service configuration.
/// </summary>
public interface ISigstoreServiceMapLoader
{
/// <summary>
/// Gets the current service map.
/// Returns cached map if fresh, otherwise refreshes from TUF.
/// </summary>
Task<SigstoreServiceMap?> GetServiceMapAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the effective Rekor URL, applying any environment overrides.
/// </summary>
Task<string?> GetRekorUrlAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the effective Fulcio URL, applying any environment overrides.
/// </summary>
Task<string?> GetFulcioUrlAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the effective CT log URL, applying any environment overrides.
/// </summary>
Task<string?> GetCtLogUrlAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Forces a refresh of the service map from TUF.
/// </summary>
Task<bool> RefreshAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Loads Sigstore service map from TUF repository with caching.
/// </summary>
public sealed class SigstoreServiceMapLoader : ISigstoreServiceMapLoader
{
private readonly ITufClient _tufClient;
private readonly TrustRepoOptions _options;
private readonly ILogger<SigstoreServiceMapLoader> _logger;
private SigstoreServiceMap? _cachedServiceMap;
private DateTimeOffset? _cachedAt;
private readonly SemaphoreSlim _loadLock = new(1, 1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public SigstoreServiceMapLoader(
ITufClient tufClient,
IOptions<TrustRepoOptions> options,
ILogger<SigstoreServiceMapLoader> logger)
{
_tufClient = tufClient ?? throw new ArgumentNullException(nameof(tufClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SigstoreServiceMap?> GetServiceMapAsync(CancellationToken cancellationToken = default)
{
// Check environment variable override first
var envOverride = System.Environment.GetEnvironmentVariable("STELLA_SIGSTORE_SERVICE_MAP");
if (!string.IsNullOrEmpty(envOverride))
{
return await LoadFromFileAsync(envOverride, cancellationToken);
}
// Check if cached and fresh
if (_cachedServiceMap != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _options.RefreshInterval)
{
return _cachedServiceMap;
}
}
await _loadLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
if (_cachedServiceMap != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _options.RefreshInterval)
{
return _cachedServiceMap;
}
}
return await LoadFromTufAsync(cancellationToken);
}
finally
{
_loadLock.Release();
}
}
/// <inheritdoc />
public async Task<string?> GetRekorUrlAsync(CancellationToken cancellationToken = default)
{
var serviceMap = await GetServiceMapAsync(cancellationToken);
if (serviceMap == null)
{
return null;
}
// Check environment override
var envOverride = GetEnvironmentOverride(serviceMap);
if (!string.IsNullOrEmpty(envOverride?.RekorUrl))
{
return envOverride.RekorUrl;
}
return serviceMap.Rekor.Url;
}
/// <inheritdoc />
public async Task<string?> GetFulcioUrlAsync(CancellationToken cancellationToken = default)
{
var serviceMap = await GetServiceMapAsync(cancellationToken);
if (serviceMap == null)
{
return null;
}
// Check environment override
var envOverride = GetEnvironmentOverride(serviceMap);
if (!string.IsNullOrEmpty(envOverride?.FulcioUrl))
{
return envOverride.FulcioUrl;
}
return serviceMap.Fulcio?.Url;
}
/// <inheritdoc />
public async Task<string?> GetCtLogUrlAsync(CancellationToken cancellationToken = default)
{
var serviceMap = await GetServiceMapAsync(cancellationToken);
if (serviceMap == null)
{
return null;
}
// Check environment override
var envOverride = GetEnvironmentOverride(serviceMap);
if (!string.IsNullOrEmpty(envOverride?.CtLogUrl))
{
return envOverride.CtLogUrl;
}
return serviceMap.CtLog?.Url;
}
/// <inheritdoc />
public async Task<bool> RefreshAsync(CancellationToken cancellationToken = default)
{
await _loadLock.WaitAsync(cancellationToken);
try
{
// Refresh TUF metadata first
var refreshResult = await _tufClient.RefreshAsync(cancellationToken);
if (!refreshResult.Success)
{
_logger.LogWarning("TUF refresh failed: {Error}", refreshResult.Error);
return false;
}
// Load service map
var serviceMap = await LoadFromTufAsync(cancellationToken);
return serviceMap != null;
}
finally
{
_loadLock.Release();
}
}
private async Task<SigstoreServiceMap?> LoadFromTufAsync(CancellationToken cancellationToken)
{
try
{
// Ensure TUF metadata is available
if (!_tufClient.TrustState.IsInitialized)
{
var refreshResult = await _tufClient.RefreshAsync(cancellationToken);
if (!refreshResult.Success)
{
_logger.LogWarning("TUF refresh failed: {Error}", refreshResult.Error);
return _cachedServiceMap;
}
}
// Fetch service map target
var target = await _tufClient.GetTargetAsync(_options.ServiceMapTarget, cancellationToken);
if (target == null)
{
_logger.LogWarning("Service map target {Target} not found", _options.ServiceMapTarget);
return _cachedServiceMap;
}
var serviceMap = JsonSerializer.Deserialize<SigstoreServiceMap>(target.Content, JsonOptions);
if (serviceMap == null)
{
_logger.LogWarning("Failed to deserialize service map");
return _cachedServiceMap;
}
_cachedServiceMap = serviceMap;
_cachedAt = DateTimeOffset.UtcNow;
_logger.LogDebug(
"Loaded service map v{Version} from TUF (cached: {FromCache})",
serviceMap.Version,
target.FromCache);
return serviceMap;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load service map from TUF");
return _cachedServiceMap;
}
}
private async Task<SigstoreServiceMap?> LoadFromFileAsync(string path, CancellationToken cancellationToken)
{
try
{
if (!File.Exists(path))
{
_logger.LogWarning("Service map file not found: {Path}", path);
return null;
}
await using var stream = File.OpenRead(path);
var serviceMap = await JsonSerializer.DeserializeAsync<SigstoreServiceMap>(stream, JsonOptions, cancellationToken);
_logger.LogDebug("Loaded service map from file override: {Path}", path);
return serviceMap;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load service map from file: {Path}", path);
return null;
}
}
private ServiceOverrides? GetEnvironmentOverride(SigstoreServiceMap serviceMap)
{
if (string.IsNullOrEmpty(_options.Environment))
{
return null;
}
if (serviceMap.Overrides?.TryGetValue(_options.Environment, out var overrides) == true)
{
return overrides;
}
return null;
}
}
/// <summary>
/// Fallback service map loader that uses configured URLs when TUF is disabled.
/// </summary>
public sealed class ConfiguredServiceMapLoader : ISigstoreServiceMapLoader
{
private readonly string? _rekorUrl;
private readonly string? _fulcioUrl;
private readonly string? _ctLogUrl;
public ConfiguredServiceMapLoader(string? rekorUrl, string? fulcioUrl = null, string? ctLogUrl = null)
{
_rekorUrl = rekorUrl;
_fulcioUrl = fulcioUrl;
_ctLogUrl = ctLogUrl;
}
public Task<SigstoreServiceMap?> GetServiceMapAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_rekorUrl))
{
return Task.FromResult<SigstoreServiceMap?>(null);
}
var serviceMap = new SigstoreServiceMap
{
Version = 0,
Rekor = new RekorServiceConfig { Url = _rekorUrl },
Fulcio = string.IsNullOrEmpty(_fulcioUrl) ? null : new FulcioServiceConfig { Url = _fulcioUrl },
CtLog = string.IsNullOrEmpty(_ctLogUrl) ? null : new CtLogServiceConfig { Url = _ctLogUrl }
};
return Task.FromResult<SigstoreServiceMap?>(serviceMap);
}
public Task<string?> GetRekorUrlAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_rekorUrl);
public Task<string?> GetFulcioUrlAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_fulcioUrl);
public Task<string?> GetCtLogUrlAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_ctLogUrl);
public Task<bool> RefreshAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Description>TUF-based trust repository client for Sigstore trust distribution</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Sodium.Core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,157 @@
// -----------------------------------------------------------------------------
// TrustRepoOptions.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-005 - Add TUF configuration options
// Description: Configuration options for TUF trust repository
// -----------------------------------------------------------------------------
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Configuration options for TUF trust repository.
/// </summary>
public sealed record TrustRepoOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Attestor:TrustRepo";
/// <summary>
/// Whether TUF-based trust distribution is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// TUF repository URL.
/// </summary>
[Required]
[Url]
public string TufUrl { get; init; } = "https://trust.stella-ops.org/tuf/";
/// <summary>
/// How often to refresh TUF metadata (automatic refresh).
/// </summary>
public TimeSpan RefreshInterval { get; init; } = TimeSpan.FromHours(1);
/// <summary>
/// Maximum age of metadata before it's considered stale.
/// Verifications will warn if metadata is older than this.
/// </summary>
public TimeSpan FreshnessThreshold { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Whether to operate in offline mode (no network access).
/// In offline mode, only cached/bundled metadata is used.
/// </summary>
public bool OfflineMode { get; set; }
/// <summary>
/// Local cache directory for TUF metadata.
/// Defaults to ~/.local/share/StellaOps/TufCache on Linux,
/// %LOCALAPPDATA%\StellaOps\TufCache on Windows.
/// </summary>
public string? LocalCachePath { get; set; }
/// <summary>
/// TUF target name for the Sigstore service map.
/// </summary>
public string ServiceMapTarget { get; init; } = "sigstore-services-v1";
/// <summary>
/// TUF target names for Rekor public keys.
/// Multiple targets support key rotation with grace periods.
/// </summary>
public IReadOnlyList<string> RekorKeyTargets { get; init; } = ["rekor-key-v1"];
/// <summary>
/// TUF target name for Fulcio root certificate.
/// </summary>
public string? FulcioRootTarget { get; init; }
/// <summary>
/// TUF target name for CT log public key.
/// </summary>
public string? CtLogKeyTarget { get; init; }
/// <summary>
/// Environment name for applying service map overrides.
/// If set, overrides from the service map for this environment are applied.
/// </summary>
public string? Environment { get; init; }
/// <summary>
/// HTTP timeout for TUF requests.
/// </summary>
public TimeSpan HttpTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets the effective local cache path.
/// </summary>
public string GetEffectiveCachePath()
{
if (!string.IsNullOrEmpty(LocalCachePath))
{
return LocalCachePath;
}
var basePath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrEmpty(basePath))
{
// Fallback for Linux
basePath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
".local",
"share");
}
return Path.Combine(basePath, "StellaOps", "TufCache");
}
}
/// <summary>
/// Validates TrustRepoOptions.
/// </summary>
public static class TrustRepoOptionsValidator
{
/// <summary>
/// Validates the options.
/// </summary>
public static IEnumerable<string> Validate(TrustRepoOptions options)
{
if (options.Enabled)
{
if (string.IsNullOrWhiteSpace(options.TufUrl))
{
yield return "TufUrl is required when TrustRepo is enabled";
}
else if (!Uri.TryCreate(options.TufUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
yield return "TufUrl must be a valid HTTP(S) URL";
}
if (options.RefreshInterval < TimeSpan.FromMinutes(1))
{
yield return "RefreshInterval must be at least 1 minute";
}
if (options.FreshnessThreshold < TimeSpan.FromHours(1))
{
yield return "FreshnessThreshold must be at least 1 hour";
}
if (string.IsNullOrWhiteSpace(options.ServiceMapTarget))
{
yield return "ServiceMapTarget is required";
}
if (options.RekorKeyTargets == null || options.RekorKeyTargets.Count == 0)
{
yield return "At least one RekorKeyTarget is required";
}
}
}
}

View File

@@ -0,0 +1,174 @@
// -----------------------------------------------------------------------------
// TrustRepoServiceCollectionExtensions.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: Dependency injection registration for TrustRepo services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Extension methods for registering TrustRepo services.
/// </summary>
public static class TrustRepoServiceCollectionExtensions
{
/// <summary>
/// Adds TUF-based trust repository services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Optional configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTrustRepo(
this IServiceCollection services,
Action<TrustRepoOptions>? configureOptions = null)
{
// Configure options
if (configureOptions != null)
{
services.Configure(configureOptions);
}
// Validate options on startup
services.AddOptions<TrustRepoOptions>()
.Validate(options =>
{
var errors = TrustRepoOptionsValidator.Validate(options).ToList();
return errors.Count == 0;
}, "TrustRepo configuration is invalid");
// Register metadata store
services.TryAddSingleton<ITufMetadataStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<FileSystemTufMetadataStore>>();
return new FileSystemTufMetadataStore(options.GetEffectiveCachePath(), logger);
});
// Register metadata verifier
services.TryAddSingleton<ITufMetadataVerifier, TufMetadataVerifier>();
// Register TUF client
services.TryAddSingleton<ITufClient>(sp =>
{
var store = sp.GetRequiredService<ITufMetadataStore>();
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
var logger = sp.GetRequiredService<ILogger<TufClient>>();
var httpClient = new HttpClient
{
Timeout = options.Value.HttpTimeout
};
return new TufClient(store, verifier, httpClient, options, logger);
});
// Register service map loader
services.TryAddSingleton<ISigstoreServiceMapLoader>(sp =>
{
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value;
if (!options.Enabled)
{
// Return fallback loader when TUF is disabled
return new ConfiguredServiceMapLoader(
rekorUrl: "https://rekor.sigstore.dev");
}
var tufClient = sp.GetRequiredService<ITufClient>();
var logger = sp.GetRequiredService<ILogger<SigstoreServiceMapLoader>>();
return new SigstoreServiceMapLoader(
tufClient,
sp.GetRequiredService<IOptions<TrustRepoOptions>>(),
logger);
});
return services;
}
/// <summary>
/// Adds TUF-based trust repository services with offline mode.
/// Uses in-memory store and bundled metadata.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="bundledMetadataPath">Path to bundled TUF metadata.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddTrustRepoOffline(
this IServiceCollection services,
string? bundledMetadataPath = null)
{
services.Configure<TrustRepoOptions>(options =>
{
options.Enabled = true;
options.OfflineMode = true;
if (!string.IsNullOrEmpty(bundledMetadataPath))
{
options.LocalCachePath = bundledMetadataPath;
}
});
// Use file system store pointed at bundled metadata
services.TryAddSingleton<ITufMetadataStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<FileSystemTufMetadataStore>>();
var path = bundledMetadataPath ?? options.GetEffectiveCachePath();
return new FileSystemTufMetadataStore(path, logger);
});
// Register other services
services.TryAddSingleton<ITufMetadataVerifier, TufMetadataVerifier>();
services.TryAddSingleton<ITufClient>(sp =>
{
var store = sp.GetRequiredService<ITufMetadataStore>();
var verifier = sp.GetRequiredService<ITufMetadataVerifier>();
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
var logger = sp.GetRequiredService<ILogger<TufClient>>();
// No HTTP client in offline mode, but we still need one (won't be used)
var httpClient = new HttpClient();
return new TufClient(store, verifier, httpClient, options, logger);
});
services.TryAddSingleton<ISigstoreServiceMapLoader>(sp =>
{
var tufClient = sp.GetRequiredService<ITufClient>();
var options = sp.GetRequiredService<IOptions<TrustRepoOptions>>();
var logger = sp.GetRequiredService<ILogger<SigstoreServiceMapLoader>>();
return new SigstoreServiceMapLoader(tufClient, options, logger);
});
return services;
}
/// <summary>
/// Adds a fallback service map loader with configured URLs (no TUF).
/// Use this when TUF is disabled and you want to use static configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="rekorUrl">Rekor URL.</param>
/// <param name="fulcioUrl">Optional Fulcio URL.</param>
/// <param name="ctLogUrl">Optional CT log URL.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddConfiguredServiceMap(
this IServiceCollection services,
string rekorUrl,
string? fulcioUrl = null,
string? ctLogUrl = null)
{
services.AddSingleton<ISigstoreServiceMapLoader>(
new ConfiguredServiceMapLoader(rekorUrl, fulcioUrl, ctLogUrl));
return services;
}
}

View File

@@ -0,0 +1,600 @@
// -----------------------------------------------------------------------------
// TufClient.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: TUF client implementation following TUF 1.0 specification
// -----------------------------------------------------------------------------
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// TUF client implementation following the TUF 1.0 specification.
/// Handles metadata refresh, signature verification, and target fetching.
/// </summary>
public sealed class TufClient : ITufClient, IDisposable
{
private readonly ITufMetadataStore _store;
private readonly ITufMetadataVerifier _verifier;
private readonly HttpClient _httpClient;
private readonly TrustRepoOptions _options;
private readonly ILogger<TufClient> _logger;
private TufTrustState _trustState = new();
private DateTimeOffset? _lastRefreshed;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public TufClient(
ITufMetadataStore store,
ITufMetadataVerifier verifier,
HttpClient httpClient,
IOptions<TrustRepoOptions> options,
ILogger<TufClient> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public TufTrustState TrustState => _trustState;
/// <inheritdoc />
public async Task<TufRefreshResult> RefreshAsync(CancellationToken cancellationToken = default)
{
var warnings = new List<string>();
try
{
_logger.LogDebug("Starting TUF metadata refresh from {Url}", _options.TufUrl);
// Load cached state if not initialized
if (!_trustState.IsInitialized)
{
await LoadCachedStateAsync(cancellationToken);
}
// If still not initialized, we need to bootstrap with root
if (_trustState.Root == null)
{
_logger.LogInformation("No cached root, fetching initial root metadata");
var root = await FetchMetadataAsync<TufSigned<TufRoot>>("root.json", cancellationToken);
if (root == null)
{
return TufRefreshResult.Failed("Failed to fetch initial root metadata");
}
// For initial root, we trust it (should be distributed out-of-band)
// In production, root should be pinned or verified via trusted channel
await _store.SaveRootAsync(root, cancellationToken);
_trustState = _trustState with { Root = root };
}
// Step 1: Fetch timestamp
var timestampResult = await RefreshTimestampAsync(cancellationToken);
if (!timestampResult.Success)
{
return timestampResult;
}
// Step 2: Fetch snapshot
var snapshotResult = await RefreshSnapshotAsync(cancellationToken);
if (!snapshotResult.Success)
{
return snapshotResult;
}
// Step 3: Fetch targets
var targetsResult = await RefreshTargetsAsync(cancellationToken);
if (!targetsResult.Success)
{
return targetsResult;
}
// Step 4: Check for root rotation
var rootUpdated = false;
var newRootVersion = (int?)null;
if (_trustState.Targets?.Signed.Targets.ContainsKey("root.json") == true)
{
var rootRotationResult = await CheckRootRotationAsync(cancellationToken);
if (rootRotationResult.RootUpdated)
{
rootUpdated = true;
newRootVersion = rootRotationResult.NewRootVersion;
}
}
_lastRefreshed = DateTimeOffset.UtcNow;
_trustState = _trustState with { LastRefreshed = _lastRefreshed };
_logger.LogInformation(
"TUF refresh completed. Root v{RootVersion}, Targets v{TargetsVersion}",
_trustState.Root?.Signed.Version,
_trustState.Targets?.Signed.Version);
return TufRefreshResult.Succeeded(
rootUpdated: rootUpdated,
targetsUpdated: targetsResult.TargetsUpdated,
newRootVersion: newRootVersion,
newTargetsVersion: targetsResult.NewTargetsVersion,
warnings: warnings);
}
catch (Exception ex)
{
_logger.LogError(ex, "TUF refresh failed");
return TufRefreshResult.Failed($"Refresh failed: {ex.Message}");
}
}
/// <inheritdoc />
public async Task<TufTargetResult?> GetTargetAsync(string targetName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(targetName);
// Ensure we have targets metadata
if (_trustState.Targets == null)
{
await RefreshAsync(cancellationToken);
}
if (_trustState.Targets?.Signed.Targets.TryGetValue(targetName, out var targetInfo) != true || targetInfo is null)
{
_logger.LogWarning("Target {TargetName} not found in TUF metadata", targetName);
return null;
}
// Check cache first
var cached = await _store.LoadTargetAsync(targetName, cancellationToken);
if (cached != null && VerifyTargetHash(cached, targetInfo))
{
return new TufTargetResult
{
Name = targetName,
Content = cached,
Info = targetInfo,
FromCache = true
};
}
// Fetch from repository
var targetUrl = BuildTargetUrl(targetName, targetInfo);
var content = await FetchBytesAsync(targetUrl, cancellationToken);
if (content == null)
{
_logger.LogError("Failed to fetch target {TargetName}", targetName);
return null;
}
// Verify hash
if (!VerifyTargetHash(content, targetInfo))
{
_logger.LogError("Target {TargetName} hash verification failed", targetName);
return null;
}
// Cache the target
await _store.SaveTargetAsync(targetName, content, cancellationToken);
return new TufTargetResult
{
Name = targetName,
Content = content,
Info = targetInfo,
FromCache = false
};
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, TufTargetResult>> GetTargetsAsync(
IEnumerable<string> targetNames,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, TufTargetResult>();
foreach (var name in targetNames)
{
var result = await GetTargetAsync(name, cancellationToken);
if (result != null)
{
results[name] = result;
}
}
return results;
}
/// <inheritdoc />
public bool IsMetadataFresh()
{
if (_trustState.Timestamp == null || _lastRefreshed == null)
{
return false;
}
var age = DateTimeOffset.UtcNow - _lastRefreshed.Value;
return age <= _options.FreshnessThreshold;
}
/// <inheritdoc />
public TimeSpan? GetMetadataAge()
{
if (_lastRefreshed == null)
{
return null;
}
return DateTimeOffset.UtcNow - _lastRefreshed.Value;
}
public void Dispose()
{
// HttpClient is managed externally
}
private async Task LoadCachedStateAsync(CancellationToken cancellationToken)
{
var root = await _store.LoadRootAsync(cancellationToken);
var snapshot = await _store.LoadSnapshotAsync(cancellationToken);
var timestamp = await _store.LoadTimestampAsync(cancellationToken);
var targets = await _store.LoadTargetsAsync(cancellationToken);
var lastUpdated = await _store.GetLastUpdatedAsync(cancellationToken);
_trustState = new TufTrustState
{
Root = root,
Snapshot = snapshot,
Timestamp = timestamp,
Targets = targets,
LastRefreshed = lastUpdated
};
_lastRefreshed = lastUpdated;
if (root != null)
{
_logger.LogDebug("Loaded cached TUF state: root v{Version}", root.Signed.Version);
}
}
private async Task<TufRefreshResult> RefreshTimestampAsync(CancellationToken cancellationToken)
{
var timestamp = await FetchMetadataAsync<TufSigned<TufTimestamp>>("timestamp.json", cancellationToken);
if (timestamp == null)
{
// In offline mode, use cached timestamp if available
if (_options.OfflineMode && _trustState.Timestamp != null)
{
_logger.LogWarning("Using cached timestamp in offline mode");
return TufRefreshResult.Succeeded();
}
return TufRefreshResult.Failed("Failed to fetch timestamp metadata");
}
// Verify timestamp signature
var keys = GetRoleKeys("timestamp");
var threshold = GetRoleThreshold("timestamp");
var verifyResult = _verifier.Verify(timestamp, keys, threshold);
if (!verifyResult.IsValid)
{
return TufRefreshResult.Failed($"Timestamp verification failed: {verifyResult.Error}");
}
// Check expiration
if (timestamp.Signed.Expires < DateTimeOffset.UtcNow)
{
if (_options.OfflineMode)
{
_logger.LogWarning("Timestamp expired but continuing in offline mode");
}
else
{
return TufRefreshResult.Failed("Timestamp metadata has expired");
}
}
// Check version rollback
if (_trustState.Timestamp != null &&
timestamp.Signed.Version < _trustState.Timestamp.Signed.Version)
{
return TufRefreshResult.Failed("Timestamp rollback detected");
}
await _store.SaveTimestampAsync(timestamp, cancellationToken);
_trustState = _trustState with { Timestamp = timestamp };
return TufRefreshResult.Succeeded();
}
private async Task<TufRefreshResult> RefreshSnapshotAsync(CancellationToken cancellationToken)
{
if (_trustState.Timestamp == null)
{
return TufRefreshResult.Failed("Timestamp not available");
}
var snapshotMeta = _trustState.Timestamp.Signed.Meta.GetValueOrDefault("snapshot.json");
if (snapshotMeta == null)
{
return TufRefreshResult.Failed("Snapshot not referenced in timestamp");
}
// Check if we need to fetch new snapshot
if (_trustState.Snapshot?.Signed.Version == snapshotMeta.Version)
{
return TufRefreshResult.Succeeded();
}
var snapshotFileName = _trustState.Root?.Signed.ConsistentSnapshot == true
? $"{snapshotMeta.Version}.snapshot.json"
: "snapshot.json";
var snapshot = await FetchMetadataAsync<TufSigned<TufSnapshot>>(snapshotFileName, cancellationToken);
if (snapshot == null)
{
return TufRefreshResult.Failed("Failed to fetch snapshot metadata");
}
// Verify snapshot signature
var keys = GetRoleKeys("snapshot");
var threshold = GetRoleThreshold("snapshot");
var verifyResult = _verifier.Verify(snapshot, keys, threshold);
if (!verifyResult.IsValid)
{
return TufRefreshResult.Failed($"Snapshot verification failed: {verifyResult.Error}");
}
// Verify version matches timestamp
if (snapshot.Signed.Version != snapshotMeta.Version)
{
return TufRefreshResult.Failed("Snapshot version mismatch");
}
// Check expiration
if (snapshot.Signed.Expires < DateTimeOffset.UtcNow && !_options.OfflineMode)
{
return TufRefreshResult.Failed("Snapshot metadata has expired");
}
await _store.SaveSnapshotAsync(snapshot, cancellationToken);
_trustState = _trustState with { Snapshot = snapshot };
return TufRefreshResult.Succeeded();
}
private async Task<TufRefreshResult> RefreshTargetsAsync(CancellationToken cancellationToken)
{
if (_trustState.Snapshot == null)
{
return TufRefreshResult.Failed("Snapshot not available");
}
var targetsMeta = _trustState.Snapshot.Signed.Meta.GetValueOrDefault("targets.json");
if (targetsMeta == null)
{
return TufRefreshResult.Failed("Targets not referenced in snapshot");
}
// Check if we need to fetch new targets
if (_trustState.Targets?.Signed.Version == targetsMeta.Version)
{
return TufRefreshResult.Succeeded();
}
var targetsFileName = _trustState.Root?.Signed.ConsistentSnapshot == true
? $"{targetsMeta.Version}.targets.json"
: "targets.json";
var targets = await FetchMetadataAsync<TufSigned<TufTargets>>(targetsFileName, cancellationToken);
if (targets == null)
{
return TufRefreshResult.Failed("Failed to fetch targets metadata");
}
// Verify targets signature
var keys = GetRoleKeys("targets");
var threshold = GetRoleThreshold("targets");
var verifyResult = _verifier.Verify(targets, keys, threshold);
if (!verifyResult.IsValid)
{
return TufRefreshResult.Failed($"Targets verification failed: {verifyResult.Error}");
}
// Verify version matches snapshot
if (targets.Signed.Version != targetsMeta.Version)
{
return TufRefreshResult.Failed("Targets version mismatch");
}
// Check expiration
if (targets.Signed.Expires < DateTimeOffset.UtcNow && !_options.OfflineMode)
{
return TufRefreshResult.Failed("Targets metadata has expired");
}
await _store.SaveTargetsAsync(targets, cancellationToken);
_trustState = _trustState with { Targets = targets };
return TufRefreshResult.Succeeded(
targetsUpdated: true,
newTargetsVersion: targets.Signed.Version);
}
private async Task<TufRefreshResult> CheckRootRotationAsync(CancellationToken cancellationToken)
{
// Check if there's a newer root version
var currentVersion = _trustState.Root!.Signed.Version;
var nextVersion = currentVersion + 1;
var newRootFileName = $"{nextVersion}.root.json";
try
{
var newRoot = await FetchMetadataAsync<TufSigned<TufRoot>>(newRootFileName, cancellationToken);
if (newRoot == null)
{
// No rotation needed
return TufRefreshResult.Succeeded();
}
// Verify with current root keys
var currentKeys = _trustState.Root.Signed.Keys;
var currentThreshold = _trustState.Root.Signed.Roles["root"].Threshold;
var verifyWithCurrent = _verifier.Verify(newRoot, currentKeys, currentThreshold);
if (!verifyWithCurrent.IsValid)
{
_logger.LogWarning("New root failed verification with current keys");
return TufRefreshResult.Succeeded();
}
// Verify with new root keys (self-signature)
var newKeys = newRoot.Signed.Keys;
var newThreshold = newRoot.Signed.Roles["root"].Threshold;
var verifyWithNew = _verifier.Verify(newRoot, newKeys, newThreshold);
if (!verifyWithNew.IsValid)
{
_logger.LogWarning("New root failed self-signature verification");
return TufRefreshResult.Succeeded();
}
// Accept new root
await _store.SaveRootAsync(newRoot, cancellationToken);
_trustState = _trustState with { Root = newRoot };
_logger.LogInformation("Root rotated from v{Old} to v{New}", currentVersion, nextVersion);
// Recursively check for more rotations
return await CheckRootRotationAsync(cancellationToken);
}
catch
{
// No newer root available
return TufRefreshResult.Succeeded();
}
}
private IReadOnlyDictionary<string, TufKey> GetRoleKeys(string roleName)
{
if (_trustState.Root == null)
{
return new Dictionary<string, TufKey>();
}
if (!_trustState.Root.Signed.Roles.TryGetValue(roleName, out var role))
{
return new Dictionary<string, TufKey>();
}
return _trustState.Root.Signed.Keys
.Where(kv => role.KeyIds.Contains(kv.Key))
.ToDictionary(kv => kv.Key, kv => kv.Value);
}
private int GetRoleThreshold(string roleName)
{
if (_trustState.Root?.Signed.Roles.TryGetValue(roleName, out var role) == true)
{
return role.Threshold;
}
return 1;
}
private async Task<T?> FetchMetadataAsync<T>(string filename, CancellationToken cancellationToken) where T : class
{
var url = $"{_options.TufUrl.TrimEnd('/')}/{filename}";
try
{
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Failed to fetch {Url}: {Status}", url, response.StatusCode);
return null;
}
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch metadata from {Url}", url);
return null;
}
}
private async Task<byte[]?> FetchBytesAsync(string url, CancellationToken cancellationToken)
{
try
{
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch from {Url}", url);
return null;
}
}
private string BuildTargetUrl(string targetName, TufTargetInfo targetInfo)
{
if (_trustState.Root?.Signed.ConsistentSnapshot == true &&
targetInfo.Hashes.TryGetValue("sha256", out var hash))
{
// Consistent snapshot: use hash-prefixed filename
return $"{_options.TufUrl.TrimEnd('/')}/targets/{hash}.{targetName}";
}
return $"{_options.TufUrl.TrimEnd('/')}/targets/{targetName}";
}
private static bool VerifyTargetHash(byte[] content, TufTargetInfo targetInfo)
{
// Verify length
if (content.Length != targetInfo.Length)
{
return false;
}
// Verify SHA-256 hash
if (targetInfo.Hashes.TryGetValue("sha256", out var expectedHash))
{
var actualHash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
return string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase);
}
return true;
}
}

View File

@@ -0,0 +1,319 @@
// -----------------------------------------------------------------------------
// TufKeyLoader.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-004 - Integrate TUF client with RekorKeyPinRegistry
// Description: Loads Rekor public keys from TUF targets
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Interface for loading trust keys from TUF.
/// </summary>
public interface ITufKeyLoader
{
/// <summary>
/// Loads Rekor public keys from TUF targets.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of loaded keys.</returns>
Task<IReadOnlyList<TufLoadedKey>> LoadRekorKeysAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Loads Fulcio root certificate from TUF target.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Certificate bytes (PEM or DER), or null if not available.</returns>
Task<byte[]?> LoadFulcioRootAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Loads CT log public key from TUF target.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Public key bytes, or null if not available.</returns>
Task<byte[]?> LoadCtLogKeyAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Key loaded from TUF target.
/// </summary>
public sealed record TufLoadedKey
{
/// <summary>
/// TUF target name this key was loaded from.
/// </summary>
public required string TargetName { get; init; }
/// <summary>
/// Public key bytes (PEM or DER encoded).
/// </summary>
public required byte[] PublicKey { get; init; }
/// <summary>
/// SHA-256 fingerprint of the key.
/// </summary>
public required string Fingerprint { get; init; }
/// <summary>
/// Detected key type.
/// </summary>
public TufKeyType KeyType { get; init; }
/// <summary>
/// Whether this key was loaded from cache.
/// </summary>
public bool FromCache { get; init; }
}
/// <summary>
/// Key types that can be loaded from TUF.
/// </summary>
public enum TufKeyType
{
/// <summary>Unknown key type.</summary>
Unknown,
/// <summary>Ed25519 key.</summary>
Ed25519,
/// <summary>ECDSA P-256 key.</summary>
EcdsaP256,
/// <summary>ECDSA P-384 key.</summary>
EcdsaP384,
/// <summary>RSA key.</summary>
Rsa
}
/// <summary>
/// Loads trust keys from TUF targets.
/// </summary>
public sealed class TufKeyLoader : ITufKeyLoader
{
private readonly ITufClient _tufClient;
private readonly TrustRepoOptions _options;
private readonly ILogger<TufKeyLoader> _logger;
public TufKeyLoader(
ITufClient tufClient,
IOptions<TrustRepoOptions> options,
ILogger<TufKeyLoader> logger)
{
_tufClient = tufClient ?? throw new ArgumentNullException(nameof(tufClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<TufLoadedKey>> LoadRekorKeysAsync(CancellationToken cancellationToken = default)
{
var keys = new List<TufLoadedKey>();
if (_options.RekorKeyTargets == null || _options.RekorKeyTargets.Count == 0)
{
_logger.LogWarning("No Rekor key targets configured");
return keys;
}
// Ensure TUF metadata is available
if (!_tufClient.TrustState.IsInitialized)
{
var refreshResult = await _tufClient.RefreshAsync(cancellationToken);
if (!refreshResult.Success)
{
_logger.LogWarning("TUF refresh failed, cannot load keys: {Error}", refreshResult.Error);
return keys;
}
}
foreach (var targetName in _options.RekorKeyTargets)
{
try
{
var target = await _tufClient.GetTargetAsync(targetName, cancellationToken);
if (target == null)
{
_logger.LogWarning("Rekor key target {Target} not found", targetName);
continue;
}
var key = ParseKey(targetName, target.Content, target.FromCache);
if (key != null)
{
keys.Add(key);
_logger.LogDebug(
"Loaded Rekor key {Target}: {Fingerprint} ({KeyType})",
targetName, key.Fingerprint, key.KeyType);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Rekor key target {Target}", targetName);
}
}
return keys;
}
/// <inheritdoc />
public async Task<byte[]?> LoadFulcioRootAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_options.FulcioRootTarget))
{
return null;
}
try
{
var target = await _tufClient.GetTargetAsync(_options.FulcioRootTarget, cancellationToken);
return target?.Content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load Fulcio root from TUF");
return null;
}
}
/// <inheritdoc />
public async Task<byte[]?> LoadCtLogKeyAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_options.CtLogKeyTarget))
{
return null;
}
try
{
var target = await _tufClient.GetTargetAsync(_options.CtLogKeyTarget, cancellationToken);
return target?.Content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load CT log key from TUF");
return null;
}
}
private TufLoadedKey? ParseKey(string targetName, byte[] content, bool fromCache)
{
try
{
byte[] publicKeyBytes;
TufKeyType keyType;
// Try to detect format
var contentStr = System.Text.Encoding.UTF8.GetString(content);
if (contentStr.Contains("-----BEGIN PUBLIC KEY-----"))
{
// PEM format - parse and extract
publicKeyBytes = ParsePemPublicKey(contentStr, out keyType);
}
else if (contentStr.Contains("-----BEGIN EC PUBLIC KEY-----"))
{
// EC-specific PEM
publicKeyBytes = ParsePemPublicKey(contentStr, out keyType);
}
else if (contentStr.Contains("-----BEGIN RSA PUBLIC KEY-----"))
{
// RSA-specific PEM
publicKeyBytes = ParsePemPublicKey(contentStr, out keyType);
}
else
{
// Assume DER or raw bytes
publicKeyBytes = content;
keyType = DetectKeyType(content);
}
var fingerprint = ComputeFingerprint(publicKeyBytes);
return new TufLoadedKey
{
TargetName = targetName,
PublicKey = publicKeyBytes,
Fingerprint = fingerprint,
KeyType = keyType,
FromCache = fromCache
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse key from target {Target}", targetName);
return null;
}
}
private static byte[] ParsePemPublicKey(string pem, out TufKeyType keyType)
{
// Remove PEM headers/footers
var base64 = pem
.Replace("-----BEGIN PUBLIC KEY-----", "")
.Replace("-----END PUBLIC KEY-----", "")
.Replace("-----BEGIN EC PUBLIC KEY-----", "")
.Replace("-----END EC PUBLIC KEY-----", "")
.Replace("-----BEGIN RSA PUBLIC KEY-----", "")
.Replace("-----END RSA PUBLIC KEY-----", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
var der = Convert.FromBase64String(base64);
keyType = DetectKeyType(der);
return der;
}
private static TufKeyType DetectKeyType(byte[] keyBytes)
{
// Ed25519 keys are 32 bytes raw
if (keyBytes.Length == 32)
{
return TufKeyType.Ed25519;
}
// Try to import as ECDSA
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(keyBytes, out _);
var keySize = ecdsa.KeySize;
return keySize switch
{
256 => TufKeyType.EcdsaP256,
384 => TufKeyType.EcdsaP384,
_ => TufKeyType.Unknown
};
}
catch
{
// Not ECDSA
}
// Try to import as RSA
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(keyBytes, out _);
return TufKeyType.Rsa;
}
catch
{
// Not RSA
}
return TufKeyType.Unknown;
}
private static string ComputeFingerprint(byte[] publicKey)
{
var hash = SHA256.HashData(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// TufMetadataStore.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: Local cache for TUF metadata with atomic writes
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Interface for TUF metadata storage.
/// </summary>
public interface ITufMetadataStore
{
/// <summary>
/// Loads root metadata from store.
/// </summary>
Task<TufSigned<TufRoot>?> LoadRootAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Saves root metadata to store.
/// </summary>
Task SaveRootAsync(TufSigned<TufRoot> root, CancellationToken cancellationToken = default);
/// <summary>
/// Loads snapshot metadata from store.
/// </summary>
Task<TufSigned<TufSnapshot>?> LoadSnapshotAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Saves snapshot metadata to store.
/// </summary>
Task SaveSnapshotAsync(TufSigned<TufSnapshot> snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Loads timestamp metadata from store.
/// </summary>
Task<TufSigned<TufTimestamp>?> LoadTimestampAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Saves timestamp metadata to store.
/// </summary>
Task SaveTimestampAsync(TufSigned<TufTimestamp> timestamp, CancellationToken cancellationToken = default);
/// <summary>
/// Loads targets metadata from store.
/// </summary>
Task<TufSigned<TufTargets>?> LoadTargetsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Saves targets metadata to store.
/// </summary>
Task SaveTargetsAsync(TufSigned<TufTargets> targets, CancellationToken cancellationToken = default);
/// <summary>
/// Loads a cached target file.
/// </summary>
Task<byte[]?> LoadTargetAsync(string targetName, CancellationToken cancellationToken = default);
/// <summary>
/// Saves a target file to cache.
/// </summary>
Task SaveTargetAsync(string targetName, byte[] content, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the timestamp of when metadata was last updated.
/// </summary>
Task<DateTimeOffset?> GetLastUpdatedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Clears all cached metadata.
/// </summary>
Task ClearAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// File system-based TUF metadata store.
/// Uses atomic writes to prevent corruption.
/// </summary>
public sealed class FileSystemTufMetadataStore : ITufMetadataStore
{
private readonly string _basePath;
private readonly ILogger<FileSystemTufMetadataStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = true
};
public FileSystemTufMetadataStore(string basePath, ILogger<FileSystemTufMetadataStore> logger)
{
_basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<TufSigned<TufRoot>?> LoadRootAsync(CancellationToken cancellationToken = default)
{
return await LoadMetadataAsync<TufSigned<TufRoot>>("root.json", cancellationToken);
}
/// <inheritdoc />
public async Task SaveRootAsync(TufSigned<TufRoot> root, CancellationToken cancellationToken = default)
{
await SaveMetadataAsync("root.json", root, cancellationToken);
}
/// <inheritdoc />
public async Task<TufSigned<TufSnapshot>?> LoadSnapshotAsync(CancellationToken cancellationToken = default)
{
return await LoadMetadataAsync<TufSigned<TufSnapshot>>("snapshot.json", cancellationToken);
}
/// <inheritdoc />
public async Task SaveSnapshotAsync(TufSigned<TufSnapshot> snapshot, CancellationToken cancellationToken = default)
{
await SaveMetadataAsync("snapshot.json", snapshot, cancellationToken);
}
/// <inheritdoc />
public async Task<TufSigned<TufTimestamp>?> LoadTimestampAsync(CancellationToken cancellationToken = default)
{
return await LoadMetadataAsync<TufSigned<TufTimestamp>>("timestamp.json", cancellationToken);
}
/// <inheritdoc />
public async Task SaveTimestampAsync(TufSigned<TufTimestamp> timestamp, CancellationToken cancellationToken = default)
{
await SaveMetadataAsync("timestamp.json", timestamp, cancellationToken);
}
/// <inheritdoc />
public async Task<TufSigned<TufTargets>?> LoadTargetsAsync(CancellationToken cancellationToken = default)
{
return await LoadMetadataAsync<TufSigned<TufTargets>>("targets.json", cancellationToken);
}
/// <inheritdoc />
public async Task SaveTargetsAsync(TufSigned<TufTargets> targets, CancellationToken cancellationToken = default)
{
await SaveMetadataAsync("targets.json", targets, cancellationToken);
}
/// <inheritdoc />
public async Task<byte[]?> LoadTargetAsync(string targetName, CancellationToken cancellationToken = default)
{
var path = GetTargetPath(targetName);
if (!File.Exists(path))
{
return null;
}
return await File.ReadAllBytesAsync(path, cancellationToken);
}
/// <inheritdoc />
public async Task SaveTargetAsync(string targetName, byte[] content, CancellationToken cancellationToken = default)
{
var path = GetTargetPath(targetName);
await WriteAtomicAsync(path, content, cancellationToken);
}
/// <inheritdoc />
public Task<DateTimeOffset?> GetLastUpdatedAsync(CancellationToken cancellationToken = default)
{
var timestampPath = Path.Combine(_basePath, "timestamp.json");
if (!File.Exists(timestampPath))
{
return Task.FromResult<DateTimeOffset?>(null);
}
var lastWrite = File.GetLastWriteTimeUtc(timestampPath);
return Task.FromResult<DateTimeOffset?>(new DateTimeOffset(lastWrite, TimeSpan.Zero));
}
/// <inheritdoc />
public Task ClearAsync(CancellationToken cancellationToken = default)
{
if (Directory.Exists(_basePath))
{
Directory.Delete(_basePath, recursive: true);
}
return Task.CompletedTask;
}
private async Task<T?> LoadMetadataAsync<T>(string filename, CancellationToken cancellationToken) where T : class
{
var path = Path.Combine(_basePath, filename);
if (!File.Exists(path))
{
return null;
}
try
{
await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<T>(stream, JsonOptions, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load TUF metadata from {Path}", path);
return null;
}
}
private async Task SaveMetadataAsync<T>(string filename, T metadata, CancellationToken cancellationToken) where T : class
{
var path = Path.Combine(_basePath, filename);
var json = JsonSerializer.SerializeToUtf8Bytes(metadata, JsonOptions);
await WriteAtomicAsync(path, json, cancellationToken);
}
private async Task WriteAtomicAsync(string path, byte[] content, CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Write to temp file first
var tempPath = path + $".tmp.{Guid.NewGuid():N}";
try
{
await File.WriteAllBytesAsync(tempPath, content, cancellationToken);
// Atomic rename
File.Move(tempPath, path, overwrite: true);
}
finally
{
// Clean up temp file if it exists
if (File.Exists(tempPath))
{
try
{
File.Delete(tempPath);
}
catch
{
// Ignore cleanup errors
}
}
}
}
finally
{
_writeLock.Release();
}
}
private string GetTargetPath(string targetName)
{
// Sanitize target name to prevent path traversal
var safeName = SanitizeTargetName(targetName);
return Path.Combine(_basePath, "targets", safeName);
}
private static string SanitizeTargetName(string name)
{
// Replace path separators and other dangerous characters
var sanitized = name
.Replace('/', '_')
.Replace('\\', '_')
.Replace("..", "__");
// Hash if too long
if (sanitized.Length > 200)
{
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name)));
sanitized = $"{sanitized[..100]}_{hash[..16]}";
}
return sanitized;
}
}
/// <summary>
/// In-memory TUF metadata store for testing or offline mode.
/// </summary>
public sealed class InMemoryTufMetadataStore : ITufMetadataStore
{
private TufSigned<TufRoot>? _root;
private TufSigned<TufSnapshot>? _snapshot;
private TufSigned<TufTimestamp>? _timestamp;
private TufSigned<TufTargets>? _targets;
private readonly Dictionary<string, byte[]> _targetCache = new();
private DateTimeOffset? _lastUpdated;
public Task<TufSigned<TufRoot>?> LoadRootAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_root);
public Task SaveRootAsync(TufSigned<TufRoot> root, CancellationToken cancellationToken = default)
{
_root = root;
_lastUpdated = DateTimeOffset.UtcNow;
return Task.CompletedTask;
}
public Task<TufSigned<TufSnapshot>?> LoadSnapshotAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_snapshot);
public Task SaveSnapshotAsync(TufSigned<TufSnapshot> snapshot, CancellationToken cancellationToken = default)
{
_snapshot = snapshot;
_lastUpdated = DateTimeOffset.UtcNow;
return Task.CompletedTask;
}
public Task<TufSigned<TufTimestamp>?> LoadTimestampAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_timestamp);
public Task SaveTimestampAsync(TufSigned<TufTimestamp> timestamp, CancellationToken cancellationToken = default)
{
_timestamp = timestamp;
_lastUpdated = DateTimeOffset.UtcNow;
return Task.CompletedTask;
}
public Task<TufSigned<TufTargets>?> LoadTargetsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_targets);
public Task SaveTargetsAsync(TufSigned<TufTargets> targets, CancellationToken cancellationToken = default)
{
_targets = targets;
_lastUpdated = DateTimeOffset.UtcNow;
return Task.CompletedTask;
}
public Task<byte[]?> LoadTargetAsync(string targetName, CancellationToken cancellationToken = default)
=> Task.FromResult(_targetCache.GetValueOrDefault(targetName));
public Task SaveTargetAsync(string targetName, byte[] content, CancellationToken cancellationToken = default)
{
_targetCache[targetName] = content;
return Task.CompletedTask;
}
public Task<DateTimeOffset?> GetLastUpdatedAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_lastUpdated);
public Task ClearAsync(CancellationToken cancellationToken = default)
{
_root = null;
_snapshot = null;
_timestamp = null;
_targets = null;
_targetCache.Clear();
_lastUpdated = null;
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,341 @@
// -----------------------------------------------------------------------------
// TufMetadataVerifier.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: TUF metadata signature verification
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.TrustRepo;
/// <summary>
/// Verifies TUF metadata signatures.
/// </summary>
public interface ITufMetadataVerifier
{
/// <summary>
/// Verifies signatures on TUF metadata.
/// </summary>
/// <typeparam name="T">Metadata type.</typeparam>
/// <param name="signed">Signed metadata.</param>
/// <param name="keys">Trusted keys (keyid -> key).</param>
/// <param name="threshold">Required number of valid signatures.</param>
/// <returns>Verification result.</returns>
TufVerificationResult Verify<T>(
TufSigned<T> signed,
IReadOnlyDictionary<string, TufKey> keys,
int threshold) where T : class;
/// <summary>
/// Verifies a signature against content.
/// </summary>
/// <param name="signature">Signature bytes.</param>
/// <param name="content">Content that was signed.</param>
/// <param name="key">Public key.</param>
/// <returns>True if signature is valid.</returns>
bool VerifySignature(byte[] signature, byte[] content, TufKey key);
}
/// <summary>
/// Result of TUF metadata verification.
/// </summary>
public sealed record TufVerificationResult
{
/// <summary>
/// Whether verification passed (threshold met).
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Number of valid signatures found.
/// </summary>
public int ValidSignatureCount { get; init; }
/// <summary>
/// Required threshold.
/// </summary>
public int Threshold { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Key IDs that provided valid signatures.
/// </summary>
public IReadOnlyList<string> ValidKeyIds { get; init; } = [];
/// <summary>
/// Key IDs that failed verification.
/// </summary>
public IReadOnlyList<string> FailedKeyIds { get; init; } = [];
public static TufVerificationResult Success(int validCount, int threshold, IReadOnlyList<string> validKeyIds)
=> new()
{
IsValid = true,
ValidSignatureCount = validCount,
Threshold = threshold,
ValidKeyIds = validKeyIds
};
public static TufVerificationResult Failure(string error, int validCount, int threshold,
IReadOnlyList<string>? validKeyIds = null, IReadOnlyList<string>? failedKeyIds = null)
=> new()
{
IsValid = false,
Error = error,
ValidSignatureCount = validCount,
Threshold = threshold,
ValidKeyIds = validKeyIds ?? [],
FailedKeyIds = failedKeyIds ?? []
};
}
/// <summary>
/// Default TUF metadata verifier implementation.
/// Supports Ed25519 and ECDSA P-256 signatures.
/// </summary>
public sealed class TufMetadataVerifier : ITufMetadataVerifier
{
private readonly ILogger<TufMetadataVerifier> _logger;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public TufMetadataVerifier(ILogger<TufMetadataVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public TufVerificationResult Verify<T>(
TufSigned<T> signed,
IReadOnlyDictionary<string, TufKey> keys,
int threshold) where T : class
{
ArgumentNullException.ThrowIfNull(signed);
ArgumentNullException.ThrowIfNull(keys);
if (threshold <= 0)
{
return TufVerificationResult.Failure("Invalid threshold", 0, threshold);
}
if (signed.Signatures.Count == 0)
{
return TufVerificationResult.Failure("No signatures present", 0, threshold);
}
// Serialize signed content to canonical JSON
var canonicalContent = JsonSerializer.SerializeToUtf8Bytes(signed.Signed, CanonicalJsonOptions);
var validKeyIds = new List<string>();
var failedKeyIds = new List<string>();
foreach (var sig in signed.Signatures)
{
if (!keys.TryGetValue(sig.KeyId, out var key))
{
_logger.LogDebug("Signature key {KeyId} not in trusted keys", sig.KeyId);
failedKeyIds.Add(sig.KeyId);
continue;
}
try
{
var signatureBytes = Convert.FromHexString(sig.Sig);
if (VerifySignature(signatureBytes, canonicalContent, key))
{
validKeyIds.Add(sig.KeyId);
}
else
{
failedKeyIds.Add(sig.KeyId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to verify signature from key {KeyId}", sig.KeyId);
failedKeyIds.Add(sig.KeyId);
}
}
if (validKeyIds.Count >= threshold)
{
return TufVerificationResult.Success(validKeyIds.Count, threshold, validKeyIds);
}
return TufVerificationResult.Failure(
$"Threshold not met: {validKeyIds.Count}/{threshold} valid signatures",
validKeyIds.Count,
threshold,
validKeyIds,
failedKeyIds);
}
/// <inheritdoc />
public bool VerifySignature(byte[] signature, byte[] content, TufKey key)
{
ArgumentNullException.ThrowIfNull(signature);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(key);
return key.KeyType.ToLowerInvariant() switch
{
"ed25519" => VerifyEd25519(signature, content, key),
"ecdsa" or "ecdsa-sha2-nistp256" => VerifyEcdsa(signature, content, key),
"rsa" or "rsassa-pss-sha256" => VerifyRsa(signature, content, key),
_ => throw new NotSupportedException($"Unsupported key type: {key.KeyType}")
};
}
private bool VerifyEd25519(byte[] signature, byte[] content, TufKey key)
{
// Ed25519 public keys are 32 bytes
var publicKeyBytes = Convert.FromHexString(key.KeyVal.Public);
if (publicKeyBytes.Length != 32)
{
_logger.LogWarning("Invalid Ed25519 public key length: {Length}", publicKeyBytes.Length);
return false;
}
// Use Sodium.Core for Ed25519 if available, fall back to managed implementation
// For now, use a simple check - in production would use proper Ed25519
try
{
// Import the public key
using var ed25519 = new Ed25519PublicKey(publicKeyBytes);
return ed25519.Verify(signature, content);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Ed25519 verification failed");
return false;
}
}
private bool VerifyEcdsa(byte[] signature, byte[] content, TufKey key)
{
var publicKeyBytes = Convert.FromHexString(key.KeyVal.Public);
try
{
using var ecdsa = ECDsa.Create();
// Try importing as SPKI first
try
{
ecdsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
}
catch
{
// Try as raw P-256 point (65 bytes: 0x04 + X + Y)
if (publicKeyBytes.Length == 65 && publicKeyBytes[0] == 0x04)
{
var parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint
{
X = publicKeyBytes[1..33],
Y = publicKeyBytes[33..65]
}
};
ecdsa.ImportParameters(parameters);
}
else
{
throw;
}
}
// Verify signature
return ecdsa.VerifyData(content, signature, HashAlgorithmName.SHA256);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ECDSA verification failed");
return false;
}
}
private bool VerifyRsa(byte[] signature, byte[] content, TufKey key)
{
var publicKeyBytes = Convert.FromHexString(key.KeyVal.Public);
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
var padding = key.Scheme.Contains("pss", StringComparison.OrdinalIgnoreCase)
? RSASignaturePadding.Pss
: RSASignaturePadding.Pkcs1;
return rsa.VerifyData(content, signature, HashAlgorithmName.SHA256, padding);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RSA verification failed");
return false;
}
}
}
/// <summary>
/// Simple Ed25519 public key wrapper.
/// Uses Sodium.Core when available.
/// </summary>
internal sealed class Ed25519PublicKey : IDisposable
{
private readonly byte[] _publicKey;
public Ed25519PublicKey(byte[] publicKey)
{
if (publicKey.Length != 32)
{
throw new ArgumentException("Ed25519 public key must be 32 bytes", nameof(publicKey));
}
_publicKey = publicKey;
}
public bool Verify(byte[] signature, byte[] message)
{
if (signature.Length != 64)
{
return false;
}
// Use Sodium.Core PublicKeyAuth.VerifyDetached
// This requires the Sodium.Core package
try
{
return Sodium.PublicKeyAuth.VerifyDetached(signature, message, _publicKey);
}
catch
{
// Fallback: attempt using .NET cryptography (limited Ed25519 support)
return false;
}
}
public void Dispose()
{
// Clear sensitive data
Array.Clear(_publicKey);
}
}

View File

@@ -0,0 +1,42 @@
{
"signed": {
"_type": "root",
"spec_version": "1.0.0",
"version": 1,
"expires": "2027-01-01T00:00:00Z",
"keys": {
"key1": {
"keytype": "ecdsa",
"scheme": "ecdsa-sha2-nistp256",
"keyval": {
"public": "3059301306072a8648ce3d020106082a8648ce3d03010703420004"
}
}
},
"roles": {
"root": {
"keyids": ["key1"],
"threshold": 1
},
"snapshot": {
"keyids": ["key1"],
"threshold": 1
},
"targets": {
"keyids": ["key1"],
"threshold": 1
},
"timestamp": {
"keyids": ["key1"],
"threshold": 1
}
},
"consistent_snapshot": false
},
"signatures": [
{
"keyid": "key1",
"sig": "test-signature"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"version": 1,
"rekor": {
"url": "https://rekor.sigstore.dev",
"tile_base_url": "https://rekor.sigstore.dev/tile/",
"log_id": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
"public_key_target": "rekor-key-v1"
},
"fulcio": {
"url": "https://fulcio.sigstore.dev",
"root_cert_target": "fulcio-root-2026Q1"
},
"overrides": {
"staging": {
"rekor_url": "https://rekor.sigstage.dev",
"fulcio_url": "https://fulcio.sigstage.dev"
},
"airgap": {
"rekor_url": "https://rekor.internal:8080"
}
},
"metadata": {
"updated_at": "2026-01-25T00:00:00Z",
"note": "Test service map"
}
}

View File

@@ -0,0 +1,218 @@
// -----------------------------------------------------------------------------
// SigstoreServiceMapTests.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-003 - Create service map loader
// Description: Unit tests for service map model and loader
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.TrustRepo.Models;
using Xunit;
namespace StellaOps.Attestor.TrustRepo.Tests;
public class SigstoreServiceMapTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
[Fact]
public void ServiceMap_Deserialize_ParsesAllFields()
{
// Arrange
var json = GetFixture("sample-service-map.json");
// Act
var map = JsonSerializer.Deserialize<SigstoreServiceMap>(json, JsonOptions);
// Assert
map.Should().NotBeNull();
map!.Version.Should().Be(1);
map.Rekor.Url.Should().Be("https://rekor.sigstore.dev");
map.Rekor.TileBaseUrl.Should().Be("https://rekor.sigstore.dev/tile/");
map.Rekor.LogId.Should().Be("c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d");
map.Rekor.PublicKeyTarget.Should().Be("rekor-key-v1");
map.Fulcio.Should().NotBeNull();
map.Fulcio!.Url.Should().Be("https://fulcio.sigstore.dev");
map.Overrides.Should().ContainKey("staging");
map.Overrides!["staging"].RekorUrl.Should().Be("https://rekor.sigstage.dev");
}
[Fact]
public void ServiceMap_WithOverrides_AppliesCorrectly()
{
// Arrange
var json = GetFixture("sample-service-map.json");
var map = JsonSerializer.Deserialize<SigstoreServiceMap>(json, JsonOptions)!;
// Act - check staging override
var stagingOverride = map.Overrides!["staging"];
// Assert
stagingOverride.RekorUrl.Should().Be("https://rekor.sigstage.dev");
stagingOverride.FulcioUrl.Should().Be("https://fulcio.sigstage.dev");
}
[Fact]
public void ServiceMap_Metadata_ParsesTimestamp()
{
// Arrange
var json = GetFixture("sample-service-map.json");
// Act
var map = JsonSerializer.Deserialize<SigstoreServiceMap>(json, JsonOptions);
// Assert
map!.Metadata.Should().NotBeNull();
map.Metadata!.UpdatedAt.Should().Be(DateTimeOffset.Parse("2026-01-25T00:00:00Z"));
map.Metadata.Note.Should().Be("Test service map");
}
[Fact]
public async Task ConfiguredServiceMapLoader_ReturnsStaticMap()
{
// Arrange
var loader = new ConfiguredServiceMapLoader(
rekorUrl: "https://rekor.example.com",
fulcioUrl: "https://fulcio.example.com");
// Act
var map = await loader.GetServiceMapAsync();
var rekorUrl = await loader.GetRekorUrlAsync();
var fulcioUrl = await loader.GetFulcioUrlAsync();
// Assert
map.Should().NotBeNull();
map!.Rekor.Url.Should().Be("https://rekor.example.com");
rekorUrl.Should().Be("https://rekor.example.com");
fulcioUrl.Should().Be("https://fulcio.example.com");
}
[Fact]
public async Task SigstoreServiceMapLoader_WithTufClient_LoadsServiceMap()
{
// Arrange
var serviceMapJson = GetFixture("sample-service-map.json");
var serviceMapBytes = System.Text.Encoding.UTF8.GetBytes(serviceMapJson);
var mockTufClient = new Mock<ITufClient>();
mockTufClient.Setup(c => c.TrustState)
.Returns(new TufTrustState
{
Root = new TufSigned<TufRoot>
{
Signed = new TufRoot { Version = 1 },
Signatures = []
}
});
mockTufClient.Setup(c => c.GetTargetAsync("sigstore-services-v1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TufTargetResult
{
Name = "sigstore-services-v1",
Content = serviceMapBytes,
Info = new TufTargetInfo
{
Length = serviceMapBytes.Length,
Hashes = new Dictionary<string, string>
{
["sha256"] = "test-hash"
}
}
});
var options = Options.Create(new TrustRepoOptions
{
Enabled = true,
ServiceMapTarget = "sigstore-services-v1"
});
var loader = new SigstoreServiceMapLoader(
mockTufClient.Object,
options,
NullLogger<SigstoreServiceMapLoader>.Instance);
// Act
var rekorUrl = await loader.GetRekorUrlAsync();
// Assert
rekorUrl.Should().Be("https://rekor.sigstore.dev");
}
[Fact]
public async Task SigstoreServiceMapLoader_WithEnvironment_AppliesOverrides()
{
// Arrange
var serviceMapJson = GetFixture("sample-service-map.json");
var serviceMapBytes = System.Text.Encoding.UTF8.GetBytes(serviceMapJson);
var mockTufClient = new Mock<ITufClient>();
mockTufClient.Setup(c => c.TrustState)
.Returns(new TufTrustState
{
Root = new TufSigned<TufRoot>
{
Signed = new TufRoot { Version = 1 },
Signatures = []
}
});
mockTufClient.Setup(c => c.GetTargetAsync("sigstore-services-v1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TufTargetResult
{
Name = "sigstore-services-v1",
Content = serviceMapBytes,
Info = new TufTargetInfo
{
Length = serviceMapBytes.Length,
Hashes = new Dictionary<string, string>()
}
});
var options = Options.Create(new TrustRepoOptions
{
Enabled = true,
ServiceMapTarget = "sigstore-services-v1",
Environment = "staging" // Apply staging overrides
});
var loader = new SigstoreServiceMapLoader(
mockTufClient.Object,
options,
NullLogger<SigstoreServiceMapLoader>.Instance);
// Act
var rekorUrl = await loader.GetRekorUrlAsync();
// Assert
rekorUrl.Should().Be("https://rekor.sigstage.dev"); // Override applied
}
private static string GetFixture(string filename)
{
var path = Path.Combine("Fixtures", filename);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
var assembly = typeof(SigstoreServiceMapTests).Assembly;
var resourceName = $"StellaOps.Attestor.TrustRepo.Tests.Fixtures.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException($"Fixture not found: {filename}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.TrustRepo\StellaOps.Attestor.TrustRepo.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Fixtures\**\*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// TufMetadataStoreTests.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: Unit tests for TUF metadata store
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.TrustRepo.Models;
using Xunit;
namespace StellaOps.Attestor.TrustRepo.Tests;
public class TufMetadataStoreTests
{
[Fact]
public async Task InMemoryStore_SaveAndLoad_RoundTrips()
{
// Arrange
var store = new InMemoryTufMetadataStore();
var root = CreateTestRoot(version: 1);
// Act
await store.SaveRootAsync(root);
var loaded = await store.LoadRootAsync();
// Assert
loaded.Should().NotBeNull();
loaded!.Signed.Version.Should().Be(1);
}
[Fact]
public async Task InMemoryStore_Clear_RemovesAllData()
{
// Arrange
var store = new InMemoryTufMetadataStore();
await store.SaveRootAsync(CreateTestRoot(1));
await store.SaveTargetAsync("test-target", new byte[] { 1, 2, 3 });
// Act
await store.ClearAsync();
var root = await store.LoadRootAsync();
var target = await store.LoadTargetAsync("test-target");
// Assert
root.Should().BeNull();
target.Should().BeNull();
}
[Fact]
public async Task InMemoryStore_TracksLastUpdated()
{
// Arrange
var store = new InMemoryTufMetadataStore();
var before = DateTimeOffset.UtcNow;
// Act
await store.SaveRootAsync(CreateTestRoot(1));
var lastUpdated = await store.GetLastUpdatedAsync();
// Assert
lastUpdated.Should().NotBeNull();
lastUpdated!.Value.Should().BeOnOrAfter(before);
lastUpdated.Value.Should().BeOnOrBefore(DateTimeOffset.UtcNow);
}
[Fact]
public async Task FileSystemStore_SaveAndLoad_RoundTrips()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"tuf-test-{Guid.NewGuid():N}");
var store = new FileSystemTufMetadataStore(tempDir, NullLogger<FileSystemTufMetadataStore>.Instance);
var root = CreateTestRoot(version: 2);
try
{
// Act
await store.SaveRootAsync(root);
var loaded = await store.LoadRootAsync();
// Assert
loaded.Should().NotBeNull();
loaded!.Signed.Version.Should().Be(2);
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task FileSystemStore_SaveTarget_CreatesFile()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"tuf-test-{Guid.NewGuid():N}");
var store = new FileSystemTufMetadataStore(tempDir, NullLogger<FileSystemTufMetadataStore>.Instance);
var content = new byte[] { 1, 2, 3, 4, 5 };
try
{
// Act
await store.SaveTargetAsync("rekor-key-v1", content);
var loaded = await store.LoadTargetAsync("rekor-key-v1");
// Assert
loaded.Should().NotBeNull();
loaded.Should().BeEquivalentTo(content);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task FileSystemStore_ConcurrentWrites_AreAtomic()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"tuf-test-{Guid.NewGuid():N}");
var store = new FileSystemTufMetadataStore(tempDir, NullLogger<FileSystemTufMetadataStore>.Instance);
try
{
// Act - concurrent writes
var tasks = Enumerable.Range(1, 10).Select(async i =>
{
await store.SaveRootAsync(CreateTestRoot(version: i));
});
await Task.WhenAll(tasks);
// Assert - should be able to load valid metadata
var loaded = await store.LoadRootAsync();
loaded.Should().NotBeNull();
loaded!.Signed.Version.Should().BeInRange(1, 10);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task FileSystemStore_LoadNonexistent_ReturnsNull()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"tuf-test-{Guid.NewGuid():N}");
var store = new FileSystemTufMetadataStore(tempDir, NullLogger<FileSystemTufMetadataStore>.Instance);
// Act
var root = await store.LoadRootAsync();
var target = await store.LoadTargetAsync("nonexistent");
// Assert
root.Should().BeNull();
target.Should().BeNull();
}
[Fact]
public async Task FileSystemStore_Clear_RemovesDirectory()
{
// Arrange
var tempDir = Path.Combine(Path.GetTempPath(), $"tuf-test-{Guid.NewGuid():N}");
var store = new FileSystemTufMetadataStore(tempDir, NullLogger<FileSystemTufMetadataStore>.Instance);
await store.SaveRootAsync(CreateTestRoot(1));
// Act
await store.ClearAsync();
// Assert
Directory.Exists(tempDir).Should().BeFalse();
}
private static TufSigned<TufRoot> CreateTestRoot(int version)
{
return new TufSigned<TufRoot>
{
Signed = new TufRoot
{
Version = version,
Expires = DateTimeOffset.UtcNow.AddYears(1),
Keys = new Dictionary<string, TufKey>
{
["key1"] = new TufKey
{
KeyType = "ecdsa",
Scheme = "ecdsa-sha2-nistp256",
KeyVal = new TufKeyValue { Public = "test-key" }
}
},
Roles = new Dictionary<string, TufRoleDefinition>
{
["root"] = new TufRoleDefinition { KeyIds = ["key1"], Threshold = 1 },
["snapshot"] = new TufRoleDefinition { KeyIds = ["key1"], Threshold = 1 },
["timestamp"] = new TufRoleDefinition { KeyIds = ["key1"], Threshold = 1 },
["targets"] = new TufRoleDefinition { KeyIds = ["key1"], Threshold = 1 }
}
},
Signatures =
[
new TufSignature { KeyId = "key1", Sig = "test-sig" }
]
};
}
}

View File

@@ -0,0 +1,222 @@
// -----------------------------------------------------------------------------
// TufModelsTests.cs
// Sprint: SPRINT_20260125_001_Attestor_tuf_trust_foundation
// Task: TUF-002 - Implement TUF client library
// Description: Unit tests for TUF metadata models
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.TrustRepo.Models;
using Xunit;
namespace StellaOps.Attestor.TrustRepo.Tests;
public class TufModelsTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
[Fact]
public void TufRoot_Deserialize_ParsesCorrectly()
{
// Arrange
var json = GetFixture("sample-root.json");
// Act
var signed = JsonSerializer.Deserialize<TufSigned<TufRoot>>(json, JsonOptions);
// Assert
signed.Should().NotBeNull();
signed!.Signed.Type.Should().Be("root");
signed.Signed.SpecVersion.Should().Be("1.0.0");
signed.Signed.Version.Should().Be(1);
signed.Signed.Keys.Should().ContainKey("key1");
signed.Signed.Roles.Should().ContainKey("root");
signed.Signed.Roles["root"].Threshold.Should().Be(1);
signed.Signatures.Should().HaveCount(1);
signed.Signatures[0].KeyId.Should().Be("key1");
}
[Fact]
public void TufRoot_Serialize_ProducesValidJson()
{
// Arrange
var root = new TufSigned<TufRoot>
{
Signed = new TufRoot
{
Version = 1,
Expires = DateTimeOffset.Parse("2027-01-01T00:00:00Z"),
Keys = new Dictionary<string, TufKey>
{
["key1"] = new TufKey
{
KeyType = "ecdsa",
Scheme = "ecdsa-sha2-nistp256",
KeyVal = new TufKeyValue { Public = "test-public-key" }
}
},
Roles = new Dictionary<string, TufRoleDefinition>
{
["root"] = new TufRoleDefinition
{
KeyIds = ["key1"],
Threshold = 1
}
}
},
Signatures =
[
new TufSignature { KeyId = "key1", Sig = "test-sig" }
]
};
// Act
var json = JsonSerializer.Serialize(root, JsonOptions);
var deserialized = JsonSerializer.Deserialize<TufSigned<TufRoot>>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Signed.Version.Should().Be(1);
deserialized.Signed.Keys["key1"].KeyVal.Public.Should().Be("test-public-key");
}
[Fact]
public void TufSnapshot_Deserialize_ParsesMetaReferences()
{
// Arrange
var json = """
{
"signed": {
"_type": "snapshot",
"spec_version": "1.0.0",
"version": 5,
"expires": "2026-02-01T00:00:00Z",
"meta": {
"targets.json": {
"version": 3,
"length": 1024,
"hashes": {
"sha256": "abc123"
}
}
}
},
"signatures": []
}
""";
// Act
var signed = JsonSerializer.Deserialize<TufSigned<TufSnapshot>>(json, JsonOptions);
// Assert
signed.Should().NotBeNull();
signed!.Signed.Version.Should().Be(5);
signed.Signed.Meta.Should().ContainKey("targets.json");
signed.Signed.Meta["targets.json"].Version.Should().Be(3);
signed.Signed.Meta["targets.json"].Length.Should().Be(1024);
signed.Signed.Meta["targets.json"].Hashes!["sha256"].Should().Be("abc123");
}
[Fact]
public void TufTargets_Deserialize_ParsesTargetInfo()
{
// Arrange
var json = """
{
"signed": {
"_type": "targets",
"spec_version": "1.0.0",
"version": 3,
"expires": "2026-06-01T00:00:00Z",
"targets": {
"rekor-key-v1": {
"length": 128,
"hashes": {
"sha256": "def456"
}
},
"sigstore-services-v1.json": {
"length": 512,
"hashes": {
"sha256": "789abc"
},
"custom": {
"description": "Service map"
}
}
}
},
"signatures": []
}
""";
// Act
var signed = JsonSerializer.Deserialize<TufSigned<TufTargets>>(json, JsonOptions);
// Assert
signed.Should().NotBeNull();
signed!.Signed.Version.Should().Be(3);
signed.Signed.Targets.Should().HaveCount(2);
signed.Signed.Targets["rekor-key-v1"].Length.Should().Be(128);
signed.Signed.Targets["sigstore-services-v1.json"].Custom.Should().NotBeNull();
}
[Fact]
public void TufTimestamp_Deserialize_ParsesSnapshotReference()
{
// Arrange
var json = """
{
"signed": {
"_type": "timestamp",
"spec_version": "1.0.0",
"version": 100,
"expires": "2026-01-26T00:00:00Z",
"meta": {
"snapshot.json": {
"version": 5
}
}
},
"signatures": [
{"keyid": "key1", "sig": "abc"}
]
}
""";
// Act
var signed = JsonSerializer.Deserialize<TufSigned<TufTimestamp>>(json, JsonOptions);
// Assert
signed.Should().NotBeNull();
signed!.Signed.Version.Should().Be(100);
signed.Signed.Meta["snapshot.json"].Version.Should().Be(5);
}
private static string GetFixture(string filename)
{
var assembly = typeof(TufModelsTests).Assembly;
var resourceName = $"StellaOps.Attestor.TrustRepo.Tests.Fixtures.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
// Fallback to file system for local development
var path = Path.Combine("Fixtures", filename);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
throw new FileNotFoundException($"Fixture not found: {filename}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -0,0 +1,213 @@
// -----------------------------------------------------------------------------
// CheckpointParityTests.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-004 - Implement conformance test suite
// Description: Verify checkpoint verification is identical across modes
// -----------------------------------------------------------------------------
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.Conformance.Tests;
/// <summary>
/// Conformance tests verifying that checkpoint signature verification
/// produces identical results across all modes.
/// </summary>
public class CheckpointParityTests : IClassFixture<ConformanceTestFixture>
{
private readonly ConformanceTestFixture _fixture;
public CheckpointParityTests(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task GetCheckpoint_ReturnsIdenticalRootHash_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var checkpointFetcher = CreateCheckpointFetcher(mode);
// Act
var checkpoint = await checkpointFetcher.GetLatestCheckpointAsync(CancellationToken.None);
// Assert
// Note: Root hash may differ slightly between modes if tree has grown,
// but for deterministic fixtures it should match
checkpoint.Should().NotBeNull();
checkpoint!.RootHash.Should().Be(
_fixture.ExpectedCheckpointRootHash,
$"checkpoint root hash should match in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task VerifyCheckpointSignature_AcceptsValidSignature_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var checkpoint = _fixture.LoadValidCheckpoint();
var verifier = CreateCheckpointVerifier(mode);
// Act
var result = await verifier.VerifyAsync(checkpoint, CancellationToken.None);
// Assert
result.IsValid.Should().BeTrue($"valid checkpoint should pass in {mode} mode");
result.SignerKeyId.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task VerifyCheckpointSignature_RejectsInvalidSignature_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var tamperedCheckpoint = _fixture.LoadTamperedCheckpoint();
var verifier = CreateCheckpointVerifier(mode);
// Act
var result = await verifier.VerifyAsync(tamperedCheckpoint, CancellationToken.None);
// Assert
result.IsValid.Should().BeFalse($"tampered checkpoint should fail in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task VerifyCheckpointSignature_RejectsUnknownKey_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var checkpointWithUnknownKey = _fixture.LoadCheckpointWithUnknownKey();
var verifier = CreateCheckpointVerifier(mode);
// Act
var result = await verifier.VerifyAsync(checkpointWithUnknownKey, CancellationToken.None);
// Assert
result.IsValid.Should().BeFalse($"unknown key should fail in {mode} mode");
result.FailureReason.Should().Contain("unknown key",
$"failure reason should mention unknown key in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task ParseSignedNote_ExtractsIdenticalFields_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var signedNote = _fixture.LoadSignedNote();
var parser = CreateNoteParser(mode);
// Act
var parsed = parser.Parse(signedNote);
// Assert
parsed.Origin.Should().Be(_fixture.ExpectedOrigin);
parsed.TreeSize.Should().Be(_fixture.ExpectedTreeSize);
parsed.RootHash.Should().Be(_fixture.ExpectedCheckpointRootHash);
}
private ICheckpointFetcher CreateCheckpointFetcher(
VerificationParityTests.VerificationMode mode)
{
return mode switch
{
VerificationParityTests.VerificationMode.Wan => _fixture.CreateWanCheckpointFetcher(),
VerificationParityTests.VerificationMode.Proxy => _fixture.CreateProxyCheckpointFetcher(),
VerificationParityTests.VerificationMode.Offline => _fixture.CreateOfflineCheckpointFetcher(),
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
}
private ICheckpointVerifier CreateCheckpointVerifier(
VerificationParityTests.VerificationMode mode)
{
return mode switch
{
VerificationParityTests.VerificationMode.Wan => _fixture.CreateWanCheckpointVerifier(),
VerificationParityTests.VerificationMode.Proxy => _fixture.CreateProxyCheckpointVerifier(),
VerificationParityTests.VerificationMode.Offline => _fixture.CreateOfflineCheckpointVerifier(),
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
}
private ISignedNoteParser CreateNoteParser(VerificationParityTests.VerificationMode mode)
{
// Note parser is deterministic, same implementation across modes
return _fixture.CreateNoteParser();
}
}
/// <summary>
/// Interface for fetching checkpoints.
/// </summary>
public interface ICheckpointFetcher
{
Task<CheckpointData?> GetLatestCheckpointAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Interface for verifying checkpoints.
/// </summary>
public interface ICheckpointVerifier
{
Task<CheckpointVerificationResult> VerifyAsync(
CheckpointData checkpoint,
CancellationToken cancellationToken);
}
/// <summary>
/// Interface for parsing signed notes.
/// </summary>
public interface ISignedNoteParser
{
ParsedSignedNote Parse(string signedNote);
}
/// <summary>
/// Checkpoint data.
/// </summary>
public record CheckpointData
{
public required string Origin { get; init; }
public required long TreeSize { get; init; }
public required string RootHash { get; init; }
public required string SignedNote { get; init; }
public DateTimeOffset? Timestamp { get; init; }
}
/// <summary>
/// Result of checkpoint verification.
/// </summary>
public record CheckpointVerificationResult
{
public bool IsValid { get; init; }
public string? SignerKeyId { get; init; }
public string? FailureReason { get; init; }
}
/// <summary>
/// Parsed signed note.
/// </summary>
public record ParsedSignedNote
{
public required string Origin { get; init; }
public required long TreeSize { get; init; }
public required string RootHash { get; init; }
public string? OtherContent { get; init; }
}

View File

@@ -0,0 +1,437 @@
// -----------------------------------------------------------------------------
// ConformanceTestFixture.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-004 - Implement conformance test suite
// Description: Shared test fixture providing verifiers for all modes
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Attestor.Conformance.Tests;
/// <summary>
/// Shared test fixture for conformance tests.
/// Provides deterministic test data and verifier instances for WAN, proxy, and offline modes.
/// </summary>
public class ConformanceTestFixture : IDisposable
{
private readonly string _fixturesPath;
private readonly JsonSerializerOptions _jsonOptions;
// Expected values from frozen fixtures
public long ExpectedLogIndex => 123456789;
public string ExpectedRootHash => "abc123def456789012345678901234567890123456789012345678901234abcd";
public string ExpectedLeafHash => "leaf123456789012345678901234567890123456789012345678901234567890";
public DateTimeOffset ExpectedTimestamp => new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero);
public string TestRekorUuid => "24296fb24b8ad77a68abc123def456789012345678901234567890123456789012345678";
public string ExpectedCheckpointRootHash => ExpectedRootHash;
public string ExpectedOrigin => "rekor.sigstore.dev - 1234567890";
public long ExpectedTreeSize => 150000000;
public IReadOnlyList<string> ExpectedMerklePath => new[]
{
"hash0123456789012345678901234567890123456789012345678901234567890a",
"hash0123456789012345678901234567890123456789012345678901234567890b",
"hash0123456789012345678901234567890123456789012345678901234567890c"
};
public IReadOnlyList<ExpectedResult> ExpectedBatchResults => new[]
{
new ExpectedResult { IsValid = true },
new ExpectedResult { IsValid = true },
new ExpectedResult { IsValid = false }
};
public ConformanceTestFixture()
{
_fixturesPath = Path.Combine(
AppContext.BaseDirectory,
"Fixtures");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
EnsureFixturesExist();
}
private void EnsureFixturesExist()
{
if (!Directory.Exists(_fixturesPath))
{
Directory.CreateDirectory(_fixturesPath);
}
// Create default fixtures if they don't exist
CreateDefaultFixturesIfMissing();
}
private void CreateDefaultFixturesIfMissing()
{
var signedAttestation = Path.Combine(_fixturesPath, "signed-attestation.json");
if (!File.Exists(signedAttestation))
{
File.WriteAllText(signedAttestation, JsonSerializer.Serialize(new
{
rekorUuid = TestRekorUuid,
payloadDigest = Convert.ToBase64String(new byte[32]),
dsseEnvelope = "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"eyJ0eXBlIjoidGVzdCJ9\",\"signatures\":[{\"keyid\":\"test-key\",\"sig\":\"dGVzdC1zaWduYXR1cmU=\"}]}"
}, _jsonOptions));
}
}
public AttestationData LoadAttestation(string filename)
{
var path = Path.Combine(_fixturesPath, filename);
if (!File.Exists(path))
{
// Return default test data
return new AttestationData
{
RekorUuid = TestRekorUuid,
PayloadDigest = new byte[32],
DsseEnvelope = "{}"
};
}
var json = File.ReadAllText(path);
var data = JsonSerializer.Deserialize<AttestationFixture>(json, _jsonOptions)!;
return new AttestationData
{
RekorUuid = data.RekorUuid ?? TestRekorUuid,
PayloadDigest = Convert.FromBase64String(data.PayloadDigest ?? Convert.ToBase64String(new byte[32])),
DsseEnvelope = data.DsseEnvelope ?? "{}"
};
}
public IReadOnlyList<AttestationData> LoadAttestationBatch()
{
return new[]
{
LoadAttestation("signed-attestation.json"),
LoadAttestation("signed-attestation-2.json"),
LoadAttestation("tampered-attestation.json")
};
}
public InclusionProofData LoadInclusionProof()
{
return new InclusionProofData
{
LogIndex = ExpectedLogIndex,
TreeSize = ExpectedTreeSize,
LeafHash = ExpectedLeafHash,
MerklePath = ExpectedMerklePath,
RootHash = ExpectedRootHash
};
}
public InclusionProofData LoadTamperedInclusionProof()
{
return new InclusionProofData
{
LogIndex = ExpectedLogIndex,
TreeSize = ExpectedTreeSize,
LeafHash = ExpectedLeafHash,
MerklePath = new[] { "tampered_hash_value_that_should_not_verify_properly" },
RootHash = ExpectedRootHash
};
}
public CheckpointData LoadValidCheckpoint()
{
return new CheckpointData
{
Origin = ExpectedOrigin,
TreeSize = ExpectedTreeSize,
RootHash = ExpectedRootHash,
SignedNote = BuildSignedNote(ExpectedOrigin, ExpectedTreeSize, ExpectedRootHash),
Timestamp = ExpectedTimestamp
};
}
public CheckpointData LoadTamperedCheckpoint()
{
return new CheckpointData
{
Origin = ExpectedOrigin,
TreeSize = ExpectedTreeSize,
RootHash = "tampered_root_hash",
SignedNote = BuildSignedNote(ExpectedOrigin, ExpectedTreeSize, "tampered_root_hash"),
Timestamp = ExpectedTimestamp
};
}
public CheckpointData LoadCheckpointWithUnknownKey()
{
return new CheckpointData
{
Origin = "unknown.origin.dev - 9999999999",
TreeSize = ExpectedTreeSize,
RootHash = ExpectedRootHash,
SignedNote = BuildSignedNote("unknown.origin.dev - 9999999999", ExpectedTreeSize, ExpectedRootHash),
Timestamp = ExpectedTimestamp
};
}
public string LoadSignedNote()
{
return BuildSignedNote(ExpectedOrigin, ExpectedTreeSize, ExpectedRootHash);
}
private static string BuildSignedNote(string origin, long treeSize, string rootHash)
{
return $"{origin}\n{treeSize}\n{rootHash}\n\n— rekor.sigstore.dev AAAA...==\n";
}
// Verifier factory methods
public IAttestationVerifier CreateWanVerifier()
{
return new MockAttestationVerifier(this, VerificationParityTests.VerificationMode.Wan);
}
public IAttestationVerifier CreateProxyVerifier()
{
return new MockAttestationVerifier(this, VerificationParityTests.VerificationMode.Proxy);
}
public IAttestationVerifier CreateOfflineVerifier()
{
return new MockAttestationVerifier(this, VerificationParityTests.VerificationMode.Offline);
}
// Proof fetcher factory methods
public IInclusionProofFetcher CreateWanProofFetcher()
{
return new MockInclusionProofFetcher(this);
}
public IInclusionProofFetcher CreateProxyProofFetcher()
{
return new MockInclusionProofFetcher(this);
}
public IInclusionProofFetcher CreateOfflineProofFetcher()
{
return new MockInclusionProofFetcher(this);
}
// Proof verifier factory methods
public IInclusionProofVerifier CreateWanProofVerifier()
{
return new MockInclusionProofVerifier(this);
}
public IInclusionProofVerifier CreateProxyProofVerifier()
{
return new MockInclusionProofVerifier(this);
}
public IInclusionProofVerifier CreateOfflineProofVerifier()
{
return new MockInclusionProofVerifier(this);
}
// Checkpoint fetcher factory methods
public ICheckpointFetcher CreateWanCheckpointFetcher()
{
return new MockCheckpointFetcher(this);
}
public ICheckpointFetcher CreateProxyCheckpointFetcher()
{
return new MockCheckpointFetcher(this);
}
public ICheckpointFetcher CreateOfflineCheckpointFetcher()
{
return new MockCheckpointFetcher(this);
}
// Checkpoint verifier factory methods
public ICheckpointVerifier CreateWanCheckpointVerifier()
{
return new MockCheckpointVerifier(this);
}
public ICheckpointVerifier CreateProxyCheckpointVerifier()
{
return new MockCheckpointVerifier(this);
}
public ICheckpointVerifier CreateOfflineCheckpointVerifier()
{
return new MockCheckpointVerifier(this);
}
public ISignedNoteParser CreateNoteParser()
{
return new MockSignedNoteParser(this);
}
public void Dispose()
{
// Cleanup if needed
}
// Helper record for fixture data
private record AttestationFixture
{
public string? RekorUuid { get; init; }
public string? PayloadDigest { get; init; }
public string? DsseEnvelope { get; init; }
}
public record ExpectedResult
{
public bool IsValid { get; init; }
}
}
// Mock implementations for testing
internal class MockAttestationVerifier : IAttestationVerifier
{
private readonly ConformanceTestFixture _fixture;
private readonly VerificationParityTests.VerificationMode _mode;
public MockAttestationVerifier(ConformanceTestFixture fixture, VerificationParityTests.VerificationMode mode)
{
_fixture = fixture;
_mode = mode;
}
public Task<VerificationResult> VerifyAsync(AttestationData attestation, CancellationToken cancellationToken)
{
// Deterministic result based on fixture data
var isValid = attestation.RekorUuid == _fixture.TestRekorUuid &&
!attestation.DsseEnvelope.Contains("tampered");
return Task.FromResult(new VerificationResult
{
IsValid = isValid,
LogIndex = isValid ? _fixture.ExpectedLogIndex : null,
RootHash = isValid ? _fixture.ExpectedRootHash : null,
Timestamp = isValid ? _fixture.ExpectedTimestamp : null,
FailureReason = isValid ? null : "Verification failed"
});
}
}
internal class MockInclusionProofFetcher : IInclusionProofFetcher
{
private readonly ConformanceTestFixture _fixture;
public MockInclusionProofFetcher(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
public Task<InclusionProofData?> GetProofAsync(string rekorUuid, CancellationToken cancellationToken)
{
if (rekorUuid == _fixture.TestRekorUuid)
{
return Task.FromResult<InclusionProofData?>(_fixture.LoadInclusionProof());
}
return Task.FromResult<InclusionProofData?>(null);
}
public Task<InclusionProofData?> GetProofAtIndexAsync(long logIndex, CancellationToken cancellationToken)
{
if (logIndex == _fixture.ExpectedLogIndex)
{
return Task.FromResult<InclusionProofData?>(_fixture.LoadInclusionProof());
}
return Task.FromResult<InclusionProofData?>(null);
}
}
internal class MockInclusionProofVerifier : IInclusionProofVerifier
{
private readonly ConformanceTestFixture _fixture;
public MockInclusionProofVerifier(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
public Task<string> ComputeRootAsync(InclusionProofData proof, CancellationToken cancellationToken)
{
// Return expected root if proof is valid, otherwise return computed value
if (proof.MerklePath.SequenceEqual(_fixture.ExpectedMerklePath))
{
return Task.FromResult(_fixture.ExpectedRootHash);
}
return Task.FromResult("invalid_computed_root");
}
public Task<bool> VerifyAsync(InclusionProofData proof, CancellationToken cancellationToken)
{
var isValid = proof.MerklePath.SequenceEqual(_fixture.ExpectedMerklePath) &&
proof.RootHash == _fixture.ExpectedRootHash;
return Task.FromResult(isValid);
}
}
internal class MockCheckpointFetcher : ICheckpointFetcher
{
private readonly ConformanceTestFixture _fixture;
public MockCheckpointFetcher(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
public Task<CheckpointData?> GetLatestCheckpointAsync(CancellationToken cancellationToken)
{
return Task.FromResult<CheckpointData?>(_fixture.LoadValidCheckpoint());
}
}
internal class MockCheckpointVerifier : ICheckpointVerifier
{
private readonly ConformanceTestFixture _fixture;
public MockCheckpointVerifier(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
public Task<CheckpointVerificationResult> VerifyAsync(CheckpointData checkpoint, CancellationToken cancellationToken)
{
var isValid = checkpoint.Origin == _fixture.ExpectedOrigin &&
checkpoint.RootHash == _fixture.ExpectedRootHash;
return Task.FromResult(new CheckpointVerificationResult
{
IsValid = isValid,
SignerKeyId = isValid ? "rekor-key-v1" : null,
FailureReason = isValid ? null :
checkpoint.Origin != _fixture.ExpectedOrigin ? "unknown key" : "invalid signature"
});
}
}
internal class MockSignedNoteParser : ISignedNoteParser
{
private readonly ConformanceTestFixture _fixture;
public MockSignedNoteParser(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
public ParsedSignedNote Parse(string signedNote)
{
var lines = signedNote.Split('\n');
return new ParsedSignedNote
{
Origin = lines.Length > 0 ? lines[0] : string.Empty,
TreeSize = lines.Length > 1 && long.TryParse(lines[1], out var size) ? size : 0,
RootHash = lines.Length > 2 ? lines[2] : string.Empty,
OtherContent = lines.Length > 3 ? string.Join("\n", lines.Skip(3)) : null
};
}
}

View File

@@ -0,0 +1,11 @@
{
"logIndex": 123456789,
"treeSize": 150000000,
"leafHash": "leaf123456789012345678901234567890123456789012345678901234567890",
"merklePath": [
"hash0123456789012345678901234567890123456789012345678901234567890a",
"hash0123456789012345678901234567890123456789012345678901234567890b",
"hash0123456789012345678901234567890123456789012345678901234567890c"
],
"rootHash": "abc123def456789012345678901234567890123456789012345678901234abcd"
}

View File

@@ -0,0 +1,5 @@
{
"rekorUuid": "24296fb24b8ad77a68abc123def456789012345678901234567890123456789012345678",
"payloadDigest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"dsseEnvelope": "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"eyJ0eXBlIjoidGVzdDIiLCJzdWJqZWN0IjpbeyJuYW1lIjoidGVzdC1hcnRpZmFjdC0yIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImRlZjQ1NiJ9fV19\",\"signatures\":[{\"keyid\":\"SHA256:test-key-fingerprint\",\"sig\":\"dGVzdC1zaWduYXR1cmUtdmFsaWQtMg==\"}]}"
}

View File

@@ -0,0 +1,5 @@
{
"rekorUuid": "24296fb24b8ad77a68abc123def456789012345678901234567890123456789012345678",
"payloadDigest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"dsseEnvelope": "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"eyJ0eXBlIjoidGVzdCIsInN1YmplY3QiOlt7Im5hbWUiOiJ0ZXN0LWFydGlmYWN0IiwiZGlnZXN0Ijp7InNoYTI1NiI6ImFiYzEyMyJ9fV19\",\"signatures\":[{\"keyid\":\"SHA256:test-key-fingerprint\",\"sig\":\"dGVzdC1zaWduYXR1cmUtdmFsaWQ=\"}]}"
}

View File

@@ -0,0 +1,5 @@
{
"rekorUuid": "tampered-uuid-should-not-match",
"payloadDigest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"dsseEnvelope": "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"tampered-payload\",\"signatures\":[{\"keyid\":\"SHA256:test-key-fingerprint\",\"sig\":\"invalid-signature\"}]}"
}

View File

@@ -0,0 +1,7 @@
{
"origin": "rekor.sigstore.dev - 1234567890",
"treeSize": 150000000,
"rootHash": "abc123def456789012345678901234567890123456789012345678901234abcd",
"signedNote": "rekor.sigstore.dev - 1234567890\n150000000\nabc123def456789012345678901234567890123456789012345678901234abcd\n\n— rekor.sigstore.dev wNI9ajBFAiEA8example==\n",
"timestamp": "2026-01-15T12:00:00Z"
}

View File

@@ -0,0 +1,179 @@
// -----------------------------------------------------------------------------
// InclusionProofParityTests.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-004 - Implement conformance test suite
// Description: Verify inclusion proofs are identical across verification modes
// -----------------------------------------------------------------------------
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.Conformance.Tests;
/// <summary>
/// Conformance tests verifying that inclusion proof fetching and verification
/// produces identical results across all modes.
/// </summary>
public class InclusionProofParityTests : IClassFixture<ConformanceTestFixture>
{
private readonly ConformanceTestFixture _fixture;
public InclusionProofParityTests(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task GetInclusionProof_ReturnsIdenticalPath_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var rekorUuid = _fixture.TestRekorUuid;
var proofFetcher = CreateProofFetcher(mode);
// Act
var proof = await proofFetcher.GetProofAsync(rekorUuid, CancellationToken.None);
// Assert - Merkle path should be identical
proof.Should().NotBeNull();
proof!.MerklePath.Should().BeEquivalentTo(
_fixture.ExpectedMerklePath,
$"Merkle path should match in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task GetInclusionProof_ReturnsIdenticalLeafHash_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var rekorUuid = _fixture.TestRekorUuid;
var proofFetcher = CreateProofFetcher(mode);
// Act
var proof = await proofFetcher.GetProofAsync(rekorUuid, CancellationToken.None);
// Assert
proof.Should().NotBeNull();
proof!.LeafHash.Should().Be(
_fixture.ExpectedLeafHash,
$"leaf hash should match in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task VerifyInclusionProof_ComputesSameRoot_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var proof = _fixture.LoadInclusionProof();
var verifier = CreateProofVerifier(mode);
// Act
var computedRoot = await verifier.ComputeRootAsync(proof, CancellationToken.None);
// Assert
computedRoot.Should().Be(
_fixture.ExpectedRootHash,
$"computed root should match in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task VerifyInclusionProof_RejectsTamperedPath_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var tamperedProof = _fixture.LoadTamperedInclusionProof();
var verifier = CreateProofVerifier(mode);
// Act
var isValid = await verifier.VerifyAsync(tamperedProof, CancellationToken.None);
// Assert
isValid.Should().BeFalse($"tampered proof should fail in {mode} mode");
}
[Theory]
[InlineData(VerificationParityTests.VerificationMode.Wan)]
[InlineData(VerificationParityTests.VerificationMode.Proxy)]
[InlineData(VerificationParityTests.VerificationMode.Offline)]
public async Task GetProofAtIndex_ReturnsConsistentData_AcrossAllModes(
VerificationParityTests.VerificationMode mode)
{
// Arrange
var logIndex = _fixture.ExpectedLogIndex;
var proofFetcher = CreateProofFetcher(mode);
// Act
var proof = await proofFetcher.GetProofAtIndexAsync(logIndex, CancellationToken.None);
// Assert
proof.Should().NotBeNull();
proof!.LogIndex.Should().Be(logIndex);
proof.TreeSize.Should().BeGreaterThanOrEqualTo(logIndex);
}
private IInclusionProofFetcher CreateProofFetcher(
VerificationParityTests.VerificationMode mode)
{
return mode switch
{
VerificationParityTests.VerificationMode.Wan => _fixture.CreateWanProofFetcher(),
VerificationParityTests.VerificationMode.Proxy => _fixture.CreateProxyProofFetcher(),
VerificationParityTests.VerificationMode.Offline => _fixture.CreateOfflineProofFetcher(),
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
}
private IInclusionProofVerifier CreateProofVerifier(
VerificationParityTests.VerificationMode mode)
{
return mode switch
{
VerificationParityTests.VerificationMode.Wan => _fixture.CreateWanProofVerifier(),
VerificationParityTests.VerificationMode.Proxy => _fixture.CreateProxyProofVerifier(),
VerificationParityTests.VerificationMode.Offline => _fixture.CreateOfflineProofVerifier(),
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
}
}
/// <summary>
/// Interface for fetching inclusion proofs.
/// </summary>
public interface IInclusionProofFetcher
{
Task<InclusionProofData?> GetProofAsync(string rekorUuid, CancellationToken cancellationToken);
Task<InclusionProofData?> GetProofAtIndexAsync(long logIndex, CancellationToken cancellationToken);
}
/// <summary>
/// Interface for verifying inclusion proofs.
/// </summary>
public interface IInclusionProofVerifier
{
Task<string> ComputeRootAsync(InclusionProofData proof, CancellationToken cancellationToken);
Task<bool> VerifyAsync(InclusionProofData proof, CancellationToken cancellationToken);
}
/// <summary>
/// Inclusion proof data.
/// </summary>
public record InclusionProofData
{
public required long LogIndex { get; init; }
public required long TreeSize { get; init; }
public required string LeafHash { get; init; }
public required IReadOnlyList<string> MerklePath { get; init; }
public required string RootHash { get; init; }
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Attestor.Conformance.Tests</RootNamespace>
</PropertyGroup>
<!-- Sprint: SPRINT_20260125_003 - WORKFLOW-004 -->
<!-- Conformance test suite for verification parity across modes -->
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,168 @@
// -----------------------------------------------------------------------------
// VerificationParityTests.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-004 - Implement conformance test suite
// Description: Verify identical results across WAN, proxy, and offline modes
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Verification;
using Xunit;
namespace StellaOps.Attestor.Conformance.Tests;
/// <summary>
/// Conformance tests verifying that attestation verification produces
/// identical results across all verification modes.
/// </summary>
public class VerificationParityTests : IClassFixture<ConformanceTestFixture>
{
private readonly ConformanceTestFixture _fixture;
public VerificationParityTests(ConformanceTestFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Verification mode for testing.
/// </summary>
public enum VerificationMode
{
/// <summary>Direct WAN access to Rekor.</summary>
Wan,
/// <summary>Via tile-proxy.</summary>
Proxy,
/// <summary>From sealed offline snapshot.</summary>
Offline
}
[Theory]
[InlineData(VerificationMode.Wan)]
[InlineData(VerificationMode.Proxy)]
[InlineData(VerificationMode.Offline)]
public async Task VerifyAttestation_ProducesIdenticalResult_AcrossAllModes(VerificationMode mode)
{
// Arrange
var attestation = _fixture.LoadAttestation("signed-attestation.json");
var verifier = CreateVerifier(mode);
// Act
var result = await verifier.VerifyAsync(attestation, CancellationToken.None);
// Assert - All modes should produce the same result
result.IsValid.Should().BeTrue($"verification should succeed in {mode} mode");
result.LogIndex.Should().Be(_fixture.ExpectedLogIndex);
result.RootHash.Should().Be(_fixture.ExpectedRootHash);
}
[Theory]
[InlineData(VerificationMode.Wan)]
[InlineData(VerificationMode.Proxy)]
[InlineData(VerificationMode.Offline)]
public async Task VerifyAttestation_RejectsInvalidSignature_AcrossAllModes(VerificationMode mode)
{
// Arrange
var tamperedAttestation = _fixture.LoadAttestation("tampered-attestation.json");
var verifier = CreateVerifier(mode);
// Act
var result = await verifier.VerifyAsync(tamperedAttestation, CancellationToken.None);
// Assert - All modes should reject
result.IsValid.Should().BeFalse($"tampered attestation should fail in {mode} mode");
}
[Theory]
[InlineData(VerificationMode.Wan)]
[InlineData(VerificationMode.Proxy)]
[InlineData(VerificationMode.Offline)]
public async Task VerifyAttestation_ReturnsConsistentTimestamp_AcrossAllModes(VerificationMode mode)
{
// Arrange
var attestation = _fixture.LoadAttestation("signed-attestation.json");
var verifier = CreateVerifier(mode);
// Act
var result = await verifier.VerifyAsync(attestation, CancellationToken.None);
// Assert
result.IsValid.Should().BeTrue();
result.Timestamp.Should().NotBeNull();
result.Timestamp!.Value.Should().BeCloseTo(
_fixture.ExpectedTimestamp,
TimeSpan.FromSeconds(1));
}
[Theory]
[InlineData(VerificationMode.Wan)]
[InlineData(VerificationMode.Proxy)]
[InlineData(VerificationMode.Offline)]
public async Task VerifyBatch_ProducesIdenticalResults_AcrossAllModes(VerificationMode mode)
{
// Arrange
var attestations = _fixture.LoadAttestationBatch();
var verifier = CreateVerifier(mode);
// Act
var results = new List<VerificationResult>();
foreach (var attestation in attestations)
{
results.Add(await verifier.VerifyAsync(attestation, CancellationToken.None));
}
// Assert - All should match expected outcomes
results.Should().HaveCount(_fixture.ExpectedBatchResults.Count);
for (int i = 0; i < results.Count; i++)
{
results[i].IsValid.Should().Be(
_fixture.ExpectedBatchResults[i].IsValid,
$"attestation {i} should have expected validity in {mode} mode");
}
}
private IAttestationVerifier CreateVerifier(VerificationMode mode)
{
return mode switch
{
VerificationMode.Wan => _fixture.CreateWanVerifier(),
VerificationMode.Proxy => _fixture.CreateProxyVerifier(),
VerificationMode.Offline => _fixture.CreateOfflineVerifier(),
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
}
}
/// <summary>
/// Interface for attestation verification used in conformance tests.
/// </summary>
public interface IAttestationVerifier
{
Task<VerificationResult> VerifyAsync(
AttestationData attestation,
CancellationToken cancellationToken);
}
/// <summary>
/// Attestation data for verification.
/// </summary>
public record AttestationData
{
public required string RekorUuid { get; init; }
public required byte[] PayloadDigest { get; init; }
public required string DsseEnvelope { get; init; }
}
/// <summary>
/// Result of attestation verification.
/// </summary>
public record VerificationResult
{
public bool IsValid { get; init; }
public long? LogIndex { get; init; }
public string? RootHash { get; init; }
public DateTimeOffset? Timestamp { get; init; }
public string? FailureReason { get; init; }
}