fix tests. new product advisories enhancements
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user