save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -28,6 +28,7 @@ public sealed class AttestationBundler : IAttestationBundler
private readonly IMerkleTreeBuilder _merkleBuilder;
private readonly ILogger<AttestationBundler> _logger;
private readonly BundlingOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Create a new attestation bundler.
@@ -38,7 +39,8 @@ public sealed class AttestationBundler : IAttestationBundler
IMerkleTreeBuilder merkleBuilder,
ILogger<AttestationBundler> logger,
IOptions<BundlingOptions> options,
IOrgKeySigner? orgSigner = null)
IOrgKeySigner? orgSigner = null,
TimeProvider? timeProvider = null)
{
_aggregator = aggregator ?? throw new ArgumentNullException(nameof(aggregator));
_store = store ?? throw new ArgumentNullException(nameof(store));
@@ -46,6 +48,7 @@ public sealed class AttestationBundler : IAttestationBundler
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new BundlingOptions();
_orgSigner = orgSigner;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -60,13 +63,42 @@ public sealed class AttestationBundler : IAttestationBundler
request.PeriodStart,
request.PeriodEnd);
// Collect attestations in deterministic order
var attestations = await CollectAttestationsAsync(request, cancellationToken);
if (attestations.Count == 0)
if (request.PeriodStart > request.PeriodEnd)
{
_logger.LogWarning("No attestations found for the specified period");
throw new InvalidOperationException("No attestations found for the specified period.");
throw new ArgumentException(
"PeriodStart must be less than or equal to PeriodEnd.",
nameof(request));
}
var effectivePeriodStart = request.PeriodStart;
var lookbackDays = _options.Aggregation.LookbackDays;
if (lookbackDays > 0)
{
var lookbackStart = request.PeriodEnd.AddDays(-lookbackDays);
if (effectivePeriodStart < lookbackStart)
{
_logger.LogDebug(
"Clamping period start from {RequestedStart} to {EffectiveStart} to honor lookback window.",
request.PeriodStart,
lookbackStart);
effectivePeriodStart = lookbackStart;
}
}
// Collect attestations in deterministic order
var attestations = await CollectAttestationsAsync(
request with { PeriodStart = effectivePeriodStart },
cancellationToken);
var minimumAttestations = Math.Max(1, _options.Aggregation.MinAttestationsForBundle);
if (attestations.Count < minimumAttestations)
{
_logger.LogWarning(
"Insufficient attestations for bundling. Required {Required}, found {Found}.",
minimumAttestations,
attestations.Count);
throw new InvalidOperationException(
$"Insufficient attestations for bundling. Required {minimumAttestations}, found {attestations.Count}.");
}
_logger.LogInformation("Collected {Count} attestations for bundling", attestations.Count);
@@ -83,8 +115,8 @@ public sealed class AttestationBundler : IAttestationBundler
{
BundleId = bundleId,
Version = "1.0",
CreatedAt = DateTimeOffset.UtcNow,
PeriodStart = request.PeriodStart,
CreatedAt = _timeProvider.GetUtcNow(),
PeriodStart = effectivePeriodStart,
PeriodEnd = request.PeriodEnd,
AttestationCount = attestations.Count,
TenantId = request.TenantId
@@ -104,6 +136,11 @@ public sealed class AttestationBundler : IAttestationBundler
};
// Sign with organization key if requested
if (request.SignWithOrgKey && _orgSigner == null)
{
throw new InvalidOperationException("Organization signer is not configured.");
}
if (request.SignWithOrgKey && _orgSigner != null)
{
bundle = await SignBundleAsync(bundle, request.OrgKeyId, cancellationToken);
@@ -146,14 +183,22 @@ public sealed class AttestationBundler : IAttestationBundler
ArgumentNullException.ThrowIfNull(bundle);
var issues = new List<BundleVerificationIssue>();
var verifiedAt = DateTimeOffset.UtcNow;
var verifiedAt = _timeProvider.GetUtcNow();
// Verify Merkle root
var merkleValid = VerifyMerkleRoot(bundle, issues);
// Verify org signature if present
bool? orgSigValid = null;
if (bundle.OrgSignature != null && _orgSigner != null)
if (bundle.OrgSignature != null && _orgSigner == null)
{
issues.Add(new BundleVerificationIssue(
VerificationIssueSeverity.Critical,
"ORG_SIG_VERIFIER_UNAVAILABLE",
"Organization signature present but no signer is configured for verification."));
orgSigValid = false;
}
else if (bundle.OrgSignature != null && _orgSigner != null)
{
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
}
@@ -236,11 +281,19 @@ public sealed class AttestationBundler : IAttestationBundler
keyId);
// Return bundle with signature and updated metadata
var fingerprint = await GetKeyFingerprintAsync(keyId, cancellationToken);
if (fingerprint == null)
{
_logger.LogWarning(
"Organization key fingerprint not found for key {KeyId}; leaving fingerprint unset.",
keyId);
}
return bundle with
{
Metadata = bundle.Metadata with
{
OrgKeyFingerprint = $"sha256:{ComputeKeyFingerprint(keyId)}"
OrgKeyFingerprint = fingerprint
},
OrgSignature = signature
};
@@ -328,10 +381,17 @@ public sealed class AttestationBundler : IAttestationBundler
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
}
private static string ComputeKeyFingerprint(string keyId)
private async Task<string?> GetKeyFingerprintAsync(
string keyId,
CancellationToken cancellationToken)
{
// Simple fingerprint - in production this would use the actual public key
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(keyId));
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
if (_orgSigner == null)
{
return null;
}
var keys = await _orgSigner.ListKeysAsync(cancellationToken) ?? Array.Empty<OrgKeyInfo>();
var match = keys.FirstOrDefault(key => string.Equals(key.KeyId, keyId, StringComparison.Ordinal));
return match?.Fingerprint;
}
}

View File

@@ -120,15 +120,18 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
private readonly IBundleStore _bundleStore;
private readonly BundlingOptions _options;
private readonly ILogger<OfflineKitBundleProvider> _logger;
private readonly TimeProvider _timeProvider;
public OfflineKitBundleProvider(
IBundleStore bundleStore,
IOptions<BundlingOptions> options,
ILogger<OfflineKitBundleProvider> logger)
ILogger<OfflineKitBundleProvider> logger,
TimeProvider? timeProvider = null)
{
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
_options = options?.Value ?? new BundlingOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
@@ -137,7 +140,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
OfflineKitExportOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new OfflineKitExportOptions();
options = ResolveExportOptions(options);
if (!_options.Export.IncludeInOfflineKit)
{
@@ -147,7 +150,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
Bundles = [],
TotalAttestations = 0,
TotalSizeBytes = 0,
ExportedAt = DateTimeOffset.UtcNow
ExportedAt = _timeProvider.GetUtcNow()
};
}
@@ -203,7 +206,7 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
Bundles = exportedBundles,
TotalAttestations = totalAttestations,
TotalSizeBytes = totalSize,
ExportedAt = DateTimeOffset.UtcNow
ExportedAt = _timeProvider.GetUtcNow()
};
}
@@ -212,9 +215,9 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
OfflineKitExportOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new OfflineKitExportOptions();
options = ResolveExportOptions(options);
var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-options.MaxAgeMonths);
var cutoffDate = _timeProvider.GetUtcNow().AddMonths(-options.MaxAgeMonths);
var result = new List<BundleListItem>();
string? cursor = null;
@@ -303,4 +306,58 @@ public sealed class OfflineKitBundleProvider : IOfflineKitBundleProvider
return $"bundle-{hash}{extension}{compression}";
}
private OfflineKitExportOptions ResolveExportOptions(OfflineKitExportOptions? options)
{
if (options != null)
{
return options;
}
return new OfflineKitExportOptions
{
MaxAgeMonths = _options.Export.MaxAgeMonths,
Format = ParseFormat(_options.Export.SupportedFormats ?? new List<string>()),
Compression = ParseCompression(_options.Export.Compression),
RequireOrgSignature = false,
TenantId = null
};
}
private static BundleFormat ParseFormat(IList<string> supportedFormats)
{
if (supportedFormats.Count == 0)
{
return BundleFormat.Json;
}
var format = supportedFormats
.FirstOrDefault(value => value.Equals("json", StringComparison.OrdinalIgnoreCase))
?? supportedFormats.FirstOrDefault()
?? "json";
return format.Equals("cbor", StringComparison.OrdinalIgnoreCase)
? BundleFormat.Cbor
: BundleFormat.Json;
}
private static BundleCompression ParseCompression(string? compression)
{
if (string.IsNullOrWhiteSpace(compression))
{
return BundleCompression.None;
}
if (compression.Equals("gzip", StringComparison.OrdinalIgnoreCase))
{
return BundleCompression.Gzip;
}
if (compression.Equals("zstd", StringComparison.OrdinalIgnoreCase))
{
return BundleCompression.Zstd;
}
return BundleCompression.None;
}
}

View File

@@ -164,25 +164,28 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
private readonly IBundleExpiryNotifier? _notifier;
private readonly BundleRetentionOptions _options;
private readonly ILogger<RetentionPolicyEnforcer> _logger;
private readonly TimeProvider _timeProvider;
public RetentionPolicyEnforcer(
IBundleStore bundleStore,
IOptions<BundlingOptions> options,
ILogger<RetentionPolicyEnforcer> logger,
IBundleArchiver? archiver = null,
IBundleExpiryNotifier? notifier = null)
IBundleExpiryNotifier? notifier = null,
TimeProvider? timeProvider = null)
{
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
_options = options?.Value?.Retention ?? new BundleRetentionOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_archiver = archiver;
_notifier = notifier;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public async Task<RetentionEnforcementResult> EnforceAsync(CancellationToken cancellationToken = default)
{
var startedAt = DateTimeOffset.UtcNow;
var startedAt = _timeProvider.GetUtcNow();
var failures = new List<BundleEnforcementFailure>();
int evaluated = 0;
int deleted = 0;
@@ -196,7 +199,7 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
return new RetentionEnforcementResult
{
StartedAt = startedAt,
CompletedAt = DateTimeOffset.UtcNow,
CompletedAt = _timeProvider.GetUtcNow(),
BundlesEvaluated = 0,
BundlesDeleted = 0,
BundlesArchived = 0,
@@ -213,10 +216,11 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
// Process bundles in batches
string? cursor = null;
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var notificationCutoff = now.AddDays(_options.NotifyDaysBeforeExpiry);
var gracePeriodCutoff = now.AddDays(-_options.GracePeriodDays);
var expiredNotifications = new List<BundleExpiryNotification>();
var applyOverrides = _options.TenantOverrides.Count > 0 || _options.PredicateTypeOverrides.Count > 0;
do
{
@@ -227,7 +231,29 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
foreach (var bundle in listResult.Bundles)
{
evaluated++;
var expiryDate = CalculateExpiryDate(bundle);
string? tenantId = null;
IReadOnlyList<string>? predicateTypes = null;
if (applyOverrides)
{
var fullBundle = await _bundleStore.GetBundleAsync(bundle.BundleId, cancellationToken);
if (fullBundle == null)
{
failures.Add(new BundleEnforcementFailure(
bundle.BundleId,
"Bundle not found",
"Failed to load bundle metadata for retention overrides."));
continue;
}
tenantId = fullBundle.Metadata.TenantId;
predicateTypes = fullBundle.Attestations
.Select(attestation => attestation.PredicateType)
.Distinct(StringComparer.Ordinal)
.ToList();
}
var expiryDate = CalculateExpiryDate(tenantId, predicateTypes, bundle.CreatedAt);
// Check if bundle has expired
if (expiryDate <= now)
@@ -300,7 +326,7 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
}
}
var completedAt = DateTimeOffset.UtcNow;
var completedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Retention enforcement completed. Evaluated={Evaluated}, Deleted={Deleted}, Archived={Archived}, Marked={Marked}, Approaching={Approaching}, Failed={Failed}",
evaluated, deleted, archived, markedExpired, approachingExpiry, failures.Count);
@@ -324,9 +350,10 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
CancellationToken cancellationToken = default)
{
var notifications = new List<BundleExpiryNotification>();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var cutoff = now.AddDays(daysBeforeExpiry);
string? cursor = null;
var applyOverrides = _options.TenantOverrides.Count > 0 || _options.PredicateTypeOverrides.Count > 0;
do
{
@@ -336,7 +363,25 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
foreach (var bundle in listResult.Bundles)
{
var expiryDate = CalculateExpiryDate(bundle);
string? tenantId = null;
IReadOnlyList<string>? predicateTypes = null;
if (applyOverrides)
{
var fullBundle = await _bundleStore.GetBundleAsync(bundle.BundleId, cancellationToken);
if (fullBundle == null)
{
continue;
}
tenantId = fullBundle.Metadata.TenantId;
predicateTypes = fullBundle.Attestations
.Select(attestation => attestation.PredicateType)
.Distinct(StringComparer.Ordinal)
.ToList();
}
var expiryDate = CalculateExpiryDate(tenantId, predicateTypes, bundle.CreatedAt);
if (expiryDate > now && expiryDate <= cutoff)
{
notifications.Add(new BundleExpiryNotification(
@@ -364,17 +409,51 @@ public sealed class RetentionPolicyEnforcer : IRetentionPolicyEnforcer
/// <inheritdoc/>
public DateTimeOffset CalculateExpiryDate(string? tenantId, DateTimeOffset createdAt)
{
int retentionMonths = _options.DefaultMonths;
var retentionMonths = ResolveRetentionMonths(tenantId, null);
return createdAt.AddMonths(retentionMonths);
}
private DateTimeOffset CalculateExpiryDate(
string? tenantId,
IReadOnlyList<string>? predicateTypes,
DateTimeOffset createdAt)
{
var retentionMonths = ResolveRetentionMonths(tenantId, predicateTypes);
return createdAt.AddMonths(retentionMonths);
}
private int ResolveRetentionMonths(
string? tenantId,
IReadOnlyList<string>? predicateTypes)
{
var retentionMonths = ClampRetentionMonths(_options.DefaultMonths);
// Check for tenant-specific override
if (!string.IsNullOrEmpty(tenantId) &&
_options.TenantOverrides.TryGetValue(tenantId, out var tenantMonths))
{
retentionMonths = Math.Max(tenantMonths, _options.MinimumMonths);
retentionMonths = Math.Min(retentionMonths, _options.MaximumMonths);
retentionMonths = ClampRetentionMonths(tenantMonths);
}
return createdAt.AddMonths(retentionMonths);
if (predicateTypes != null && _options.PredicateTypeOverrides.Count > 0)
{
foreach (var predicateType in predicateTypes)
{
if (_options.PredicateTypeOverrides.TryGetValue(predicateType, out var predicateMonths))
{
retentionMonths = Math.Max(retentionMonths, ClampRetentionMonths(predicateMonths));
}
}
}
return retentionMonths;
}
private int ClampRetentionMonths(int months)
{
var clamped = Math.Max(months, _options.MinimumMonths);
return Math.Min(clamped, _options.MaximumMonths);
}
private async Task<(bool Success, BundleEnforcementFailure? Failure)> HandleExpiredBundleAsync(

View File

@@ -6,10 +6,10 @@
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>

View File

@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Bundling</RootNamespace>
<Description>Attestation bundle aggregation and rotation for long-term verification in air-gapped environments.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0047-M | DONE | Maintainability audit for StellaOps.Attestor.Bundling. |
| AUDIT-0047-T | DONE | Test coverage audit for StellaOps.Attestor.Bundling. |
| AUDIT-0047-A | TODO | Pending approval for changes. |
| AUDIT-0047-A | DONE | Applied bundling validation, defaults, and test coverage updates. |