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