audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -1,3 +1,5 @@
using StellaOps.Determinism;
namespace StellaOps.Provcache.Events;
/// <summary>
@@ -91,6 +93,8 @@ public sealed record FeedEpochAdvancedEvent
/// <param name="correlationId">Correlation ID for tracing.</param>
/// <param name="eventId">Optional event ID (defaults to new GUID).</param>
/// <param name="timestamp">Optional timestamp (defaults to current UTC time).</param>
/// <param name="guidProvider">Optional GUID provider for deterministic IDs.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static FeedEpochAdvancedEvent Create(
string feedId,
string previousEpoch,
@@ -102,12 +106,17 @@ public sealed record FeedEpochAdvancedEvent
string? tenantId = null,
string? correlationId = null,
Guid? eventId = null,
DateTimeOffset? timestamp = null)
DateTimeOffset? timestamp = null,
IGuidProvider? guidProvider = null,
TimeProvider? timeProvider = null)
{
var guidSource = guidProvider ?? SystemGuidProvider.Instance;
var timeSource = timeProvider ?? TimeProvider.System;
return new FeedEpochAdvancedEvent
{
EventId = eventId ?? Guid.NewGuid(),
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
EventId = eventId ?? guidSource.NewGuid(),
Timestamp = timestamp ?? timeSource.GetUtcNow(),
FeedId = feedId,
PreviousEpoch = previousEpoch,
NewEpoch = newEpoch,

View File

@@ -1,3 +1,5 @@
using StellaOps.Determinism;
namespace StellaOps.Provcache.Events;
/// <summary>
@@ -80,6 +82,8 @@ public sealed record SignerRevokedEvent
/// <param name="correlationId">Correlation ID for tracing.</param>
/// <param name="eventId">Optional event ID (defaults to new GUID).</param>
/// <param name="timestamp">Optional timestamp (defaults to current UTC time).</param>
/// <param name="guidProvider">Optional GUID provider for deterministic IDs.</param>
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
public static SignerRevokedEvent Create(
Guid anchorId,
string keyId,
@@ -89,12 +93,17 @@ public sealed record SignerRevokedEvent
string? actor = null,
string? correlationId = null,
Guid? eventId = null,
DateTimeOffset? timestamp = null)
DateTimeOffset? timestamp = null,
IGuidProvider? guidProvider = null,
TimeProvider? timeProvider = null)
{
var guidSource = guidProvider ?? SystemGuidProvider.Instance;
var timeSource = timeProvider ?? TimeProvider.System;
return new SignerRevokedEvent
{
EventId = eventId ?? Guid.NewGuid(),
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
EventId = eventId ?? guidSource.NewGuid(),
Timestamp = timestamp ?? timeSource.GetUtcNow(),
AnchorId = anchorId,
KeyId = keyId,
SignerHash = signerHash,

View File

@@ -1,6 +1,8 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Canonical.Json;
using StellaOps.Cryptography;
using StellaOps.Determinism;
using StellaOps.Provenance.Attestation;
@@ -15,6 +17,8 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
private readonly IProvcacheService _provcacheService;
private readonly IEvidenceChunkRepository _chunkRepository;
private readonly ISigner? _signer;
private readonly ICryptoHmac? _cryptoHmac;
private readonly IKeyProvider? _keyProvider;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<MinimalProofExporter> _logger;
@@ -32,11 +36,15 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
ISigner? signer = null,
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null,
ILogger<MinimalProofExporter>? logger = null)
ILogger<MinimalProofExporter>? logger = null,
ICryptoHmac? cryptoHmac = null,
IKeyProvider? keyProvider = null)
{
_provcacheService = provcacheService ?? throw new ArgumentNullException(nameof(provcacheService));
_chunkRepository = chunkRepository ?? throw new ArgumentNullException(nameof(chunkRepository));
_signer = signer;
_cryptoHmac = cryptoHmac;
_keyProvider = keyProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<MinimalProofExporter>.Instance;
@@ -114,7 +122,7 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
CancellationToken cancellationToken = default)
{
var bundle = await ExportAsync(veriKey, options, cancellationToken);
return JsonSerializer.SerializeToUtf8Bytes(bundle, s_jsonOptions);
return CanonJson.Canonicalize(bundle, s_jsonOptions);
}
/// <inheritdoc />
@@ -127,7 +135,8 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
ArgumentNullException.ThrowIfNull(outputStream);
var bundle = await ExportAsync(veriKey, options, cancellationToken);
await JsonSerializer.SerializeAsync(outputStream, bundle, s_jsonOptions, cancellationToken);
var payload = CanonJson.Canonicalize(bundle, s_jsonOptions);
await outputStream.WriteAsync(payload, cancellationToken);
}
/// <inheritdoc />
@@ -384,19 +393,20 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
// Serialize bundle without signature for signing
var bundleWithoutSig = bundle with { Signature = null };
var payload = JsonSerializer.SerializeToUtf8Bytes(bundleWithoutSig, s_jsonOptions);
var payload = CanonJson.Canonicalize(bundleWithoutSig, s_jsonOptions);
var signRequest = new SignRequest(
Payload: payload,
ContentType: "application/vnd.stellaops.proof-bundle+json");
var signResult = await _signer.SignAsync(signRequest, cancellationToken);
var algorithm = _cryptoHmac?.GetAlgorithmForPurpose(HmacPurpose.Signing) ?? "HMAC-SHA256";
return bundle with
{
Signature = new BundleSignature
{
Algorithm = "HMAC-SHA256", // Could be made configurable
Algorithm = algorithm,
KeyId = signResult.KeyId,
SignatureBytes = Convert.ToBase64String(signResult.Signature),
SignedAt = signResult.SignedAt
@@ -436,11 +446,44 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
private bool VerifySignature(MinimalProofBundle bundle)
{
// For now, we don't have signature verification implemented
// This would require the signer's public key or certificate
// Return true as a placeholder - signature presence is enough for MVP
_logger.LogWarning("Signature verification not fully implemented - assuming valid");
return bundle.Signature is not null;
if (bundle.Signature is null)
{
return false;
}
if (_cryptoHmac is null || _keyProvider is null)
{
_logger.LogWarning("Signature verification skipped: no HMAC verifier or key configured");
return false;
}
if (!string.Equals(bundle.Signature.KeyId, _keyProvider.KeyId, StringComparison.Ordinal))
{
_logger.LogWarning(
"Signature key mismatch: expected {Expected}, got {Actual}",
_keyProvider.KeyId,
bundle.Signature.KeyId);
return false;
}
var expectedAlgorithm = _cryptoHmac.GetAlgorithmForPurpose(HmacPurpose.Signing);
if (!string.Equals(bundle.Signature.Algorithm, expectedAlgorithm, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning(
"Signature algorithm mismatch: expected {Expected}, got {Actual}",
expectedAlgorithm,
bundle.Signature.Algorithm);
return false;
}
var bundleWithoutSig = bundle with { Signature = null };
var payload = CanonJson.Canonicalize(bundleWithoutSig, s_jsonOptions);
return _cryptoHmac.VerifyHmacBase64ForPurpose(
_keyProvider.KeyMaterial,
payload,
bundle.Signature.SignatureBytes,
HmacPurpose.Signing);
}
private static long CalculateChunkDataSize(ChunkManifest manifest, int chunkCount)

View File

@@ -1,3 +1,4 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
@@ -11,10 +12,21 @@ namespace StellaOps.Provcache;
/// </summary>
public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
{
/// <summary>
/// Named client for use with IHttpClientFactory.
/// </summary>
public const string HttpClientName = "provcache-lazy-fetch";
private static readonly string[] DefaultSchemes = ["https", "http"];
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly ILogger<HttpChunkFetcher> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly LazyFetchHttpOptions _options;
private readonly IReadOnlyList<string> _allowedHosts;
private readonly IReadOnlySet<string> _allowedSchemes;
private readonly bool _allowAllHosts;
/// <inheritdoc />
public string FetcherType => "http";
@@ -23,9 +35,15 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
/// Creates an HTTP chunk fetcher with the specified base URL.
/// </summary>
/// <param name="baseUrl">The base URL of the Stella API.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
/// <param name="logger">Logger instance.</param>
public HttpChunkFetcher(Uri baseUrl, ILogger<HttpChunkFetcher> logger)
: this(CreateClient(baseUrl), ownsClient: true, logger)
/// <param name="options">Lazy fetch HTTP options.</param>
public HttpChunkFetcher(
Uri baseUrl,
IHttpClientFactory httpClientFactory,
ILogger<HttpChunkFetcher> logger,
LazyFetchHttpOptions? options = null)
: this(CreateClient(httpClientFactory, baseUrl), ownsClient: false, logger, options)
{
}
@@ -35,25 +53,149 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
/// <param name="httpClient">The HTTP client to use.</param>
/// <param name="ownsClient">Whether this fetcher owns the client lifecycle.</param>
/// <param name="logger">Logger instance.</param>
public HttpChunkFetcher(HttpClient httpClient, bool ownsClient, ILogger<HttpChunkFetcher> logger)
/// <param name="options">Lazy fetch HTTP options.</param>
public HttpChunkFetcher(
HttpClient httpClient,
bool ownsClient,
ILogger<HttpChunkFetcher> logger,
LazyFetchHttpOptions? options = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_ownsClient = ownsClient;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? new LazyFetchHttpOptions();
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
var baseAddress = _httpClient.BaseAddress
?? throw new InvalidOperationException("HttpChunkFetcher requires a BaseAddress on the HTTP client.");
_allowedSchemes = NormalizeSchemes(_options.AllowedSchemes);
_allowedHosts = NormalizeHosts(_options.AllowedHosts, baseAddress.Host, out _allowAllHosts);
ValidateBaseAddress(baseAddress);
ApplyClientConfiguration();
}
private static HttpClient CreateClient(Uri baseUrl)
private static HttpClient CreateClient(IHttpClientFactory httpClientFactory, Uri baseUrl)
{
var client = new HttpClient { BaseAddress = baseUrl };
client.DefaultRequestHeaders.Add("Accept", "application/json");
ArgumentNullException.ThrowIfNull(httpClientFactory);
ArgumentNullException.ThrowIfNull(baseUrl);
var client = httpClientFactory.CreateClient(HttpClientName);
client.BaseAddress = baseUrl;
return client;
}
private void ApplyClientConfiguration()
{
var timeout = _options.Timeout;
if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan)
{
throw new InvalidOperationException("Lazy fetch HTTP timeout must be a positive, non-infinite duration.");
}
if (_httpClient.Timeout == Timeout.InfiniteTimeSpan || _httpClient.Timeout > timeout)
{
_httpClient.Timeout = timeout;
}
if (!_httpClient.DefaultRequestHeaders.Accept.Any(header =>
string.Equals(header.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)))
{
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
}
private void ValidateBaseAddress(Uri baseAddress)
{
if (!baseAddress.IsAbsoluteUri)
{
throw new InvalidOperationException("Lazy fetch base URL must be absolute.");
}
if (!string.IsNullOrWhiteSpace(baseAddress.UserInfo))
{
throw new InvalidOperationException("Lazy fetch base URL must not include user info.");
}
if (string.IsNullOrWhiteSpace(baseAddress.Host))
{
throw new InvalidOperationException("Lazy fetch base URL must include a host.");
}
if (!_allowedSchemes.Contains(baseAddress.Scheme))
{
throw new InvalidOperationException($"Lazy fetch base URL scheme '{baseAddress.Scheme}' is not allowed.");
}
if (!_allowAllHosts && !IsHostAllowed(baseAddress.Host, _allowedHosts))
{
throw new InvalidOperationException($"Lazy fetch base URL host '{baseAddress.Host}' is not allowlisted.");
}
}
private static IReadOnlySet<string> NormalizeSchemes(IList<string> schemes)
{
var normalized = schemes
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.ToArray();
if (normalized.Length == 0)
{
normalized = DefaultSchemes;
}
return new HashSet<string>(normalized, StringComparer.OrdinalIgnoreCase);
}
private static IReadOnlyList<string> NormalizeHosts(
IList<string> hosts,
string baseHost,
out bool allowAllHosts)
{
var normalized = hosts
.Where(h => !string.IsNullOrWhiteSpace(h))
.Select(h => h.Trim())
.ToList();
allowAllHosts = normalized.Any(h => string.Equals(h, "*", StringComparison.Ordinal));
if (!allowAllHosts && normalized.Count == 0)
{
normalized.Add(baseHost);
}
return normalized;
}
private static bool IsHostAllowed(string host, IReadOnlyList<string> allowedHosts)
{
foreach (var allowed in allowedHosts)
{
if (string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (allowed.StartsWith("*.", StringComparison.Ordinal))
{
var suffix = allowed[1..];
if (host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
/// <inheritdoc />
public async Task<FetchedChunk?> FetchChunkAsync(
string proofRoot,

View File

@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Provcache;
/// <summary>
/// Options for HTTP lazy evidence fetching.
/// </summary>
public sealed class LazyFetchHttpOptions
{
/// <summary>
/// Configuration section name under Provcache.
/// </summary>
public const string SectionName = "LazyFetchHttp";
/// <summary>
/// HTTP timeout for fetch requests.
/// Default: 10 seconds.
/// </summary>
[Range(typeof(TimeSpan), "00:00:01", "00:05:00", ErrorMessage = "Timeout must be between 1 second and 5 minutes")]
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Allowlisted hostnames for HTTP fetches.
/// Supports exact match and "*.example.com" suffix entries.
/// </summary>
public IList<string> AllowedHosts { get; } = new List<string>();
/// <summary>
/// Allowlisted schemes for HTTP fetches.
/// When empty, defaults to http and https.
/// </summary>
public IList<string> AllowedSchemes { get; } = new List<string>();
}

View File

@@ -5,8 +5,10 @@
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
namespace StellaOps.Provcache.Oci;
@@ -16,11 +18,12 @@ namespace StellaOps.Provcache.Oci;
/// </summary>
public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBuilder
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false // Deterministic output
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly TimeProvider _timeProvider;
@@ -53,8 +56,8 @@ public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBui
};
// Serialize to canonical JSON (deterministic)
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
var statementBytes = CanonJson.Canonicalize(statement, CanonicalOptions);
var statementJson = Encoding.UTF8.GetString(statementBytes);
// Build OCI annotations
var annotations = BuildAnnotations(request, predicate);
@@ -275,7 +278,7 @@ public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBui
["stellaops.provcache.verikey"] = predicate.VeriKey,
["stellaops.provcache.verdict-hash"] = predicate.VerdictHash,
["stellaops.provcache.proof-root"] = predicate.ProofRoot,
["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(),
["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(CultureInfo.InvariantCulture),
["stellaops.provcache.expires-at"] = predicate.ExpiresAt
};

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
namespace StellaOps.Provcache;
@@ -144,6 +145,6 @@ public sealed class ProvcacheOptions
{
var bucketTicks = TimeWindowBucket.Ticks;
var epoch = timestamp.UtcTicks / bucketTicks * bucketTicks;
return new DateTimeOffset(epoch, TimeSpan.Zero).ToString("yyyy-MM-ddTHH:mm:ssZ");
return new DateTimeOffset(epoch, TimeSpan.Zero).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Provcache;
@@ -21,8 +22,18 @@ public static class ProvcacheServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var section = configuration.GetSection(ProvcacheOptions.SectionName);
// Register options
services.Configure<ProvcacheOptions>(configuration.GetSection(ProvcacheOptions.SectionName));
services.AddOptions<ProvcacheOptions>()
.Bind(section)
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<LazyFetchHttpOptions>()
.Bind(section.GetSection(LazyFetchHttpOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Register core services
services.AddSingleton<IProvcacheService, ProvcacheService>();
@@ -31,6 +42,7 @@ public static class ProvcacheServiceCollectionExtensions
services.AddSingleton<WriteBehindQueue>();
services.AddSingleton<IWriteBehindQueue>(sp => sp.GetRequiredService<WriteBehindQueue>());
services.AddHostedService(sp => sp.GetRequiredService<WriteBehindQueue>());
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
return services;
}
@@ -49,7 +61,14 @@ public static class ProvcacheServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(configure);
// Register options
services.Configure(configure);
services.AddOptions<ProvcacheOptions>()
.Configure(configure)
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<LazyFetchHttpOptions>()
.ValidateDataAnnotations()
.ValidateOnStart();
// Register core services
services.AddSingleton<IProvcacheService, ProvcacheService>();
@@ -58,6 +77,7 @@ public static class ProvcacheServiceCollectionExtensions
services.AddSingleton<WriteBehindQueue>();
services.AddSingleton<IWriteBehindQueue>(sp => sp.GetRequiredService<WriteBehindQueue>());
services.AddHostedService(sp => sp.GetRequiredService<WriteBehindQueue>());
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
return services;
}

View File

@@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />

View File

@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0101-M | DONE | Revalidated 2026-01-08; maintainability audit for Provcache core. |
| AUDIT-0101-T | DONE | Revalidated 2026-01-08; test coverage audit for Provcache core. |
| AUDIT-0101-A | TODO | Pending approval (revalidated 2026-01-08). |
| AUDIT-0101-A | DONE | Applied 2026-01-13; hotlist fixes and tests added. |

View File

@@ -133,7 +133,7 @@ public sealed class WriteBehindQueue : BackgroundService, IWriteBehindQueue
}
// Drain remaining items on shutdown
await DrainAsync(CancellationToken.None).ConfigureAwait(false);
await DrainAsync(stoppingToken).ConfigureAwait(false);
_logger.LogInformation("Write-behind queue stopped");
}