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. |

View File

@@ -39,6 +39,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
private readonly Func<string?, EnvelopeKey?> _keyResolver;
private readonly IRekorClient? _rekorClient;
private readonly GraphRootAttestorOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GraphRootAttestor> _logger;
/// <summary>
@@ -56,7 +57,8 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
Func<string?, EnvelopeKey?> keyResolver,
ILogger<GraphRootAttestor> logger,
IRekorClient? rekorClient = null,
IOptions<GraphRootAttestorOptions>? options = null)
IOptions<GraphRootAttestorOptions>? options = null,
TimeProvider? timeProvider = null)
{
_merkleComputer = merkleComputer ?? throw new ArgumentNullException(nameof(merkleComputer));
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
@@ -64,6 +66,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_rekorClient = rekorClient;
_options = options?.Value ?? new GraphRootAttestorOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -91,14 +94,20 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
var normalizedPolicyDigest = NormalizeDigest(request.PolicyDigest);
var normalizedFeedsDigest = NormalizeDigest(request.FeedsDigest);
var normalizedToolchainDigest = NormalizeDigest(request.ToolchainDigest);
var normalizedParamsDigest = NormalizeDigest(request.ParamsDigest);
// 2. Build leaf data for Merkle tree
var leaves = BuildLeaves(
sortedNodeIds,
sortedEdgeIds,
request.PolicyDigest,
request.FeedsDigest,
request.ToolchainDigest,
request.ParamsDigest);
sortedEvidenceIds,
normalizedPolicyDigest,
normalizedFeedsDigest,
normalizedToolchainDigest,
normalizedParamsDigest);
// 3. Compute Merkle root
var rootBytes = _merkleComputer.ComputeRoot(leaves);
@@ -108,7 +117,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
_logger.LogDebug("Computed Merkle root: {RootHash}", rootHash);
// 4. Build in-toto statement
var computedAt = DateTimeOffset.UtcNow;
var computedAt = _timeProvider.GetUtcNow();
var attestation = BuildAttestation(
request,
sortedNodeIds,
@@ -116,6 +125,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
sortedEvidenceIds,
rootHash,
rootHex,
normalizedPolicyDigest,
normalizedFeedsDigest,
normalizedToolchainDigest,
normalizedParamsDigest,
computedAt);
// 5. Canonicalize the attestation
@@ -129,7 +142,7 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
$"Unable to resolve signing key: {request.SigningKeyId ?? "(default)"}");
}
var signResult = _signatureService.Sign(payload, key, ct);
var signResult = _signatureService.SignDsse(PayloadType, payload, key, ct);
if (!signResult.IsSuccess)
{
throw new InvalidOperationException(
@@ -260,8 +273,8 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
};
// Compute bundle hash
var bundleJson = JsonSerializer.Serialize(EnvDsseEnvelope);
var bundleHash = SHA256.HashData(Encoding.UTF8.GetBytes(bundleJson));
var bundleJson = CanonJson.Canonicalize(EnvDsseEnvelope);
var bundleHash = SHA256.HashData(bundleJson);
return new AttestorSubmissionRequest
{
@@ -303,6 +316,24 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
nodes.Count,
edges.Count);
if (!string.Equals(envelope.PayloadType, PayloadType, StringComparison.Ordinal))
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = $"Unexpected payloadType '{envelope.PayloadType}'."
};
}
if (!TryVerifyEnvelopeSignatures(envelope, ct, out var signatureFailure))
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = signatureFailure ?? "No valid DSSE signatures found."
};
}
// 1. Deserialize attestation from envelope payload
GraphRootAttestation? attestation;
try
@@ -336,15 +367,69 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
.Select(e => e.EdgeId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
var predicateNodeIds = attestation.Predicate.NodeIds?.ToList() ?? [];
var predicateEdgeIds = attestation.Predicate.EdgeIds?.ToList() ?? [];
var predicateEvidenceIds = attestation.Predicate.EvidenceIds?.ToList() ?? [];
if (!SequenceEqual(predicateNodeIds, recomputedNodeIds))
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = "Predicate node IDs do not match provided graph data."
};
}
if (!SequenceEqual(predicateEdgeIds, recomputedEdgeIds))
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = "Predicate edge IDs do not match provided graph data."
};
}
var sortedPredicateEvidenceIds = predicateEvidenceIds
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
if (!SequenceEqual(predicateEvidenceIds, sortedPredicateEvidenceIds))
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = "Predicate evidence IDs are not in deterministic order."
};
}
string normalizedPolicyDigest;
string normalizedFeedsDigest;
string normalizedToolchainDigest;
string normalizedParamsDigest;
try
{
normalizedPolicyDigest = NormalizeDigest(attestation.Predicate.Inputs.PolicyDigest);
normalizedFeedsDigest = NormalizeDigest(attestation.Predicate.Inputs.FeedsDigest);
normalizedToolchainDigest = NormalizeDigest(attestation.Predicate.Inputs.ToolchainDigest);
normalizedParamsDigest = NormalizeDigest(attestation.Predicate.Inputs.ParamsDigest);
}
catch (ArgumentException ex)
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = $"Invalid predicate digest: {ex.Message}"
};
}
// 3. Build leaves using the same inputs from the attestation
var leaves = BuildLeaves(
recomputedNodeIds,
recomputedEdgeIds,
attestation.Predicate.Inputs.PolicyDigest,
attestation.Predicate.Inputs.FeedsDigest,
attestation.Predicate.Inputs.ToolchainDigest,
attestation.Predicate.Inputs.ParamsDigest);
sortedPredicateEvidenceIds,
normalizedPolicyDigest,
normalizedFeedsDigest,
normalizedToolchainDigest,
normalizedParamsDigest);
// 4. Compute Merkle root
var recomputedRootBytes = _merkleComputer.ComputeRoot(leaves);
@@ -385,13 +470,14 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
private static List<ReadOnlyMemory<byte>> BuildLeaves(
IReadOnlyList<string> sortedNodeIds,
IReadOnlyList<string> sortedEdgeIds,
IReadOnlyList<string> sortedEvidenceIds,
string policyDigest,
string feedsDigest,
string toolchainDigest,
string paramsDigest)
{
var leaves = new List<ReadOnlyMemory<byte>>(
sortedNodeIds.Count + sortedEdgeIds.Count + 4);
sortedNodeIds.Count + sortedEdgeIds.Count + sortedEvidenceIds.Count + 4);
// Add node IDs
foreach (var nodeId in sortedNodeIds)
@@ -405,6 +491,12 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
leaves.Add(Encoding.UTF8.GetBytes(edgeId));
}
// Add evidence IDs
foreach (var evidenceId in sortedEvidenceIds)
{
leaves.Add(Encoding.UTF8.GetBytes(evidenceId));
}
// Add input digests (deterministic order)
leaves.Add(Encoding.UTF8.GetBytes(policyDigest));
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest));
@@ -421,6 +513,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
IReadOnlyList<string> sortedEvidenceIds,
string rootHash,
string rootHex,
string policyDigest,
string feedsDigest,
string toolchainDigest,
string paramsDigest,
DateTimeOffset computedAt)
{
var subjects = new List<GraphRootSubject>
@@ -457,10 +553,10 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
EdgeIds = sortedEdgeIds,
Inputs = new GraphInputDigests
{
PolicyDigest = request.PolicyDigest,
FeedsDigest = request.FeedsDigest,
ToolchainDigest = request.ToolchainDigest,
ParamsDigest = request.ParamsDigest
PolicyDigest = policyDigest,
FeedsDigest = feedsDigest,
ToolchainDigest = toolchainDigest,
ParamsDigest = paramsDigest
},
EvidenceIds = sortedEvidenceIds,
CanonVersion = CanonVersion.Current,
@@ -476,13 +572,13 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
var colonIndex = digest.IndexOf(':');
if (colonIndex > 0 && colonIndex < digest.Length - 1)
{
var algorithm = digest[..colonIndex];
var value = digest[(colonIndex + 1)..];
var algorithm = digest[..colonIndex].ToLowerInvariant();
var value = digest[(colonIndex + 1)..].ToLowerInvariant();
return new Dictionary<string, string> { [algorithm] = value };
}
// Assume sha256 if no algorithm prefix
return new Dictionary<string, string> { ["sha256"] = digest };
return new Dictionary<string, string> { ["sha256"] = digest.ToLowerInvariant() };
}
private static string GetToolVersion()
@@ -493,4 +589,104 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
?? "1.0.0";
return version;
}
private bool TryVerifyEnvelopeSignatures(
EnvDsseEnvelope envelope,
CancellationToken ct,
out string? failureReason)
{
if (envelope.Signatures.Count == 0)
{
failureReason = "Envelope does not contain signatures.";
return false;
}
foreach (var signature in envelope.Signatures)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
continue;
}
var key = _keyResolver(signature.KeyId);
if (key is null)
{
continue;
}
if (!string.Equals(signature.KeyId, key.KeyId, StringComparison.Ordinal))
{
continue;
}
if (!TryDecodeSignature(signature.Signature, out var signatureBytes))
{
continue;
}
var envelopeSignature = new EnvelopeSignature(signature.KeyId, key.AlgorithmId, signatureBytes);
var verified = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, key, ct);
if (verified.IsSuccess)
{
failureReason = null;
return true;
}
}
failureReason = "DSSE signature verification failed.";
return false;
}
private static bool TryDecodeSignature(string signature, out byte[] signatureBytes)
{
try
{
signatureBytes = Convert.FromBase64String(signature);
return signatureBytes.Length > 0;
}
catch (FormatException)
{
signatureBytes = [];
return false;
}
}
private static bool SequenceEqual(IReadOnlyList<string> left, IReadOnlyList<string> right)
{
if (left.Count != right.Count)
{
return false;
}
for (var i = 0; i < left.Count; i++)
{
if (!string.Equals(left[i], right[i], StringComparison.Ordinal))
{
return false;
}
}
return true;
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest must be provided.", nameof(digest));
}
var trimmed = digest.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex > 0 && colonIndex < trimmed.Length - 1)
{
var algorithm = trimmed[..colonIndex].ToLowerInvariant();
var value = trimmed[(colonIndex + 1)..].ToLowerInvariant();
return $"{algorithm}:{value}";
}
return $"sha256:{trimmed.ToLowerInvariant()}";
}
}

View File

@@ -1,3 +1,4 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Attestor.Envelope;
@@ -18,6 +19,7 @@ public static class GraphRootServiceCollectionExtensions
{
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
services.TryAddSingleton<EnvelopeSignatureService>();
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IGraphRootAttestor, GraphRootAttestor>();
return services;
@@ -37,14 +39,16 @@ public static class GraphRootServiceCollectionExtensions
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
services.TryAddSingleton<EnvelopeSignatureService>();
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IGraphRootAttestor>(sp =>
{
var merkleComputer = sp.GetRequiredService<IMerkleRootComputer>();
var signatureService = sp.GetRequiredService<EnvelopeSignatureService>();
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<GraphRootAttestor>>();
var resolver = keyResolver(sp);
var timeProvider = sp.GetService<TimeProvider>();
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger);
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger, timeProvider: timeProvider);
});
return services;

View File

@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0053-M | DONE | Maintainability audit for StellaOps.Attestor.GraphRoot. |
| AUDIT-0053-T | DONE | Test coverage audit for StellaOps.Attestor.GraphRoot. |
| AUDIT-0053-A | TODO | Pending approval for changes. |
| AUDIT-0053-A | DONE | Applied audit remediation for graph root attestation. |

View File

@@ -91,9 +91,20 @@ public sealed record OciReference
/// <summary>
/// Gets the full reference string.
/// </summary>
public string FullReference => Tag is not null
? $"{Registry}/{Repository}:{Tag}"
: $"{Registry}/{Repository}@{Digest}";
public string FullReference
{
get
{
var baseRef = $"{Registry}/{Repository}";
if (!string.IsNullOrWhiteSpace(Digest))
{
return $"{baseRef}@{Digest}";
}
var tag = string.IsNullOrWhiteSpace(Tag) ? "latest" : Tag;
return $"{baseRef}:{tag}";
}
}
/// <summary>
/// Parses an OCI reference string.
@@ -102,45 +113,43 @@ public sealed record OciReference
{
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
// Handle digest references: registry/repo@sha256:...
string? digest = null;
var name = reference;
var digestIndex = reference.IndexOf('@');
if (digestIndex > 0)
if (digestIndex >= 0)
{
var beforeDigest = reference[..digestIndex];
var digest = reference[(digestIndex + 1)..];
var (registry, repo) = ParseRegistryAndRepo(beforeDigest);
return new OciReference
{
Registry = registry,
Repository = repo,
Digest = digest
};
}
// Handle tag references: registry/repo:tag
var tagIndex = reference.LastIndexOf(':');
if (tagIndex > 0)
{
var beforeTag = reference[..tagIndex];
var tag = reference[(tagIndex + 1)..];
// Check if this is actually a port number
if (!beforeTag.Contains('/') || tag.Contains('/'))
name = reference[..digestIndex];
digest = reference[(digestIndex + 1)..];
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
}
var (registry, repo) = ParseRegistryAndRepo(beforeTag);
return new OciReference
{
Registry = registry,
Repository = repo,
Digest = string.Empty, // Will be resolved
Tag = tag
};
}
throw new ArgumentException($"Invalid OCI reference: {reference}", nameof(reference));
string? tag = null;
var tagIndex = name.LastIndexOf(':');
var slashIndex = name.LastIndexOf('/');
if (tagIndex > slashIndex)
{
tag = name[(tagIndex + 1)..];
name = name[..tagIndex];
}
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
{
tag = "latest";
}
var (registry, repo) = ParseRegistryAndRepo(name);
return new OciReference
{
Registry = registry,
Repository = repo,
Digest = digest ?? string.Empty,
Tag = tag
};
}
private static (string Registry, string Repo) ParseRegistryAndRepo(string reference)
@@ -148,13 +157,35 @@ public sealed record OciReference
var firstSlash = reference.IndexOf('/');
if (firstSlash < 0)
{
throw new ArgumentException($"Invalid OCI reference: {reference}");
return ("docker.io", NormalizeRepository("docker.io", reference));
}
var registry = reference[..firstSlash];
var repo = reference[(firstSlash + 1)..];
var firstSegment = reference[..firstSlash];
if (IsRegistryHost(firstSegment))
{
var repo = reference[(firstSlash + 1)..];
return (firstSegment, NormalizeRepository(firstSegment, repo));
}
return (registry, repo);
return ("docker.io", NormalizeRepository("docker.io", reference));
}
private static bool IsRegistryHost(string value)
{
return value.Contains('.', StringComparison.Ordinal)
|| value.Contains(':', StringComparison.Ordinal)
|| string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeRepository(string registry, string repository)
{
if (string.Equals(registry, "docker.io", StringComparison.OrdinalIgnoreCase)
&& !repository.Contains('/', StringComparison.Ordinal))
{
return $"library/{repository}";
}
return repository;
}
}

View File

@@ -19,19 +19,16 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
{
private readonly IOciRegistryClient _registryClient;
private readonly ILogger<OrasAttestationAttacher> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private readonly TimeProvider _timeProvider;
public OrasAttestationAttacher(
IOciRegistryClient registryClient,
ILogger<OrasAttestationAttacher> logger)
ILogger<OrasAttestationAttacher> logger,
TimeProvider? timeProvider = null)
{
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
@@ -46,6 +43,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
options ??= new AttachmentOptions();
var predicateType = ResolvePredicateType(attestation);
_logger.LogInformation(
"Attaching attestation to {Registry}/{Repository}@{Digest}",
imageRef.Registry,
@@ -66,18 +65,18 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
{
var existing = await FindExistingAttestationAsync(
imageRef,
attestation.PayloadType,
predicateType,
ct).ConfigureAwait(false);
if (existing is not null)
{
_logger.LogWarning(
"Attestation with predicate type {PredicateType} already exists at {Digest}",
attestation.PayloadType,
predicateType,
TruncateDigest(existing.Digest));
throw new InvalidOperationException(
$"Attestation with predicate type '{attestation.PayloadType}' already exists. " +
$"Attestation with predicate type '{predicateType}' already exists. " +
"Use ReplaceExisting=true to overwrite.");
}
}
@@ -104,7 +103,8 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
ct).ConfigureAwait(false);
// 5. Build manifest with subject reference
var annotations = BuildAnnotations(attestation, options);
var attachedAt = _timeProvider.GetUtcNow();
var annotations = BuildAnnotations(attestation, predicateType, options, attachedAt);
var manifest = new OciManifest
{
SchemaVersion = 2,
@@ -131,7 +131,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
Size = attestationBytes.Length,
Annotations = new Dictionary<string, string>
{
[AnnotationKeys.PredicateType] = attestation.PayloadType
[AnnotationKeys.PredicateType] = predicateType
}
}
],
@@ -153,11 +153,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
TruncateDigest(imageRef.Digest),
TruncateDigest(manifestDigest));
if (options.RecordInRekor)
{
_logger.LogWarning("RecordInRekor requested but Rekor integration is not configured for OCI attachments.");
}
return new AttachmentResult
{
AttestationDigest = attestationDigest,
AttestationRef = $"{imageRef.Registry}/{imageRef.Repository}@{manifestDigest}",
AttachedAt = DateTimeOffset.UtcNow
AttachedAt = attachedAt,
RekorLogId = null
};
}
@@ -259,7 +265,17 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
return null;
}
var layerDigest = manifest.Layers[0].Digest;
var layer = manifest.Layers.FirstOrDefault(l =>
string.Equals(l.MediaType, MediaTypes.DsseEnvelope, StringComparison.Ordinal));
if (layer is null)
{
_logger.LogWarning(
"Attestation manifest {Digest} has no DSSE envelope layer",
TruncateDigest(target.Digest));
return null;
}
var layerDigest = layer.Digest;
// Fetch the attestation blob
var blobBytes = await _registryClient.FetchBlobAsync(
@@ -305,12 +321,14 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
private static Dictionary<string, string> BuildAnnotations(
DsseEnvelope envelope,
AttachmentOptions options)
string predicateType,
AttachmentOptions options,
DateTimeOffset createdAt)
{
var annotations = new Dictionary<string, string>
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
{
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O"),
[AnnotationKeys.PredicateType] = envelope.PayloadType,
[AnnotationKeys.Created] = createdAt.ToString("O"),
[AnnotationKeys.PredicateType] = predicateType,
[AnnotationKeys.CosignSignature] = "" // Cosign compatibility placeholder
};
@@ -351,7 +369,7 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
private static DsseEnvelope DeserializeEnvelope(ReadOnlyMemory<byte> bytes)
{
// Parse the compact DSSE envelope format
var json = JsonDocument.Parse(bytes);
using var json = JsonDocument.Parse(bytes);
var root = json.RootElement;
var payloadType = root.GetProperty("payloadType").GetString()
@@ -360,7 +378,15 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
var payloadBase64 = root.GetProperty("payload").GetString()
?? throw new InvalidOperationException("Missing payload");
var payload = Convert.FromBase64String(payloadBase64);
byte[] payload;
try
{
payload = Convert.FromBase64String(payloadBase64);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Attestation payload is not valid base64.", ex);
}
var signatures = new List<DsseSignature>();
if (root.TryGetProperty("signatures", out var sigsElement))
@@ -381,6 +407,41 @@ public sealed class OrasAttestationAttacher : IOciAttestationAttacher
return new DsseEnvelope(payloadType, payload, signatures);
}
private static string ResolvePredicateType(DsseEnvelope envelope)
{
if (TryGetPredicateType(envelope.Payload.Span, out var predicateType))
{
return predicateType;
}
return envelope.PayloadType;
}
private static bool TryGetPredicateType(ReadOnlySpan<byte> payload, out string predicateType)
{
try
{
using var json = JsonDocument.Parse(payload.ToArray());
if (json.RootElement.TryGetProperty("predicateType", out var predicateElement)
&& predicateElement.ValueKind == JsonValueKind.String)
{
var value = predicateElement.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
predicateType = value;
return true;
}
}
}
catch (JsonException)
{
// Swallow and fallback to payload type
}
predicateType = string.Empty;
return false;
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
{
var hash = SHA256.HashData(content);

View File

@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Attestor.Oci</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -7,4 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0056-M | DONE | Maintainability audit for StellaOps.Attestor.Oci. |
| AUDIT-0056-T | DONE | Test coverage audit for StellaOps.Attestor.Oci. |
| AUDIT-0056-A | TODO | Pending approval for changes. |
| AUDIT-0056-A | DONE | Applied audit remediation for OCI attacher and references. |
| VAL-SMOKE-001 | DONE | Fixed build issue in Attestor OCI attacher. |

View File

@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
<Description>Offline verification of attestation bundles for 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.Bundle\StellaOps.Attestor.Bundle.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
| AUDIT-0058-A | TODO | Pending approval for changes. |
| AUDIT-0058-A | DOING | Pending approval for changes. |

View File

@@ -50,7 +50,7 @@ public class AuditLogEntity
/// Additional details about the operation.
/// </summary>
[Column("details", TypeName = "jsonb")]
public JsonDocument? Details { get; set; }
public JsonElement? Details { get; set; }
/// <summary>
/// When this log entry was created.

View File

@@ -53,7 +53,7 @@ public class RekorEntryEntity
/// </summary>
[Required]
[Column("inclusion_proof", TypeName = "jsonb")]
public JsonDocument InclusionProof { get; set; } = null!;
public JsonElement InclusionProof { get; set; }
/// <summary>
/// When this record was created.

View File

@@ -16,7 +16,7 @@ function Resolve-RepoRoot {
$repoRoot = Resolve-RepoRoot
$perfDir = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf"
$migrationFile = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/20251214000001_AddProofChainSchema.sql"
$migrationFile = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/001_initial_schema.sql"
$seedFile = Join-Path $perfDir "seed.sql"
$queriesFile = Join-Path $perfDir "queries.sql"
$reportFile = Join-Path $repoRoot "docs/db/reports/proofchain-schema-perf-2025-12-17.md"

View File

@@ -57,6 +57,9 @@ public class ProofChainDbContext : DbContext
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
entity.HasIndex(e => e.TrustAnchorId).HasDatabaseName("idx_sbom_entries_anchor");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
// Unique constraint
entity.HasIndex(e => new { e.BomDigest, e.Purl, e.Version })
@@ -87,6 +90,9 @@ public class ProofChainDbContext : DbContext
.HasDatabaseName("idx_dsse_entry_predicate");
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
entity.HasIndex(e => e.BodyHash).HasDatabaseName("idx_dsse_body_hash");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
// Unique constraint
entity.HasIndex(e => new { e.EntryId, e.PredicateType, e.BodyHash })
@@ -100,6 +106,9 @@ public class ProofChainDbContext : DbContext
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
entity.HasOne(e => e.Anchor)
.WithMany()
@@ -114,6 +123,12 @@ public class ProofChainDbContext : DbContext
entity.HasIndex(e => e.IsActive)
.HasDatabaseName("idx_trust_anchors_active")
.HasFilter("is_active = TRUE");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAddOrUpdate();
});
// RekorEntryEntity configuration
@@ -123,6 +138,9 @@ public class ProofChainDbContext : DbContext
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
entity.HasIndex(e => e.EnvId).HasDatabaseName("idx_rekor_env");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
entity.HasOne(e => e.Envelope)
.WithOne(e => e.RekorEntry)
@@ -138,6 +156,70 @@ public class ProofChainDbContext : DbContext
entity.HasIndex(e => e.CreatedAt)
.HasDatabaseName("idx_audit_created")
.IsDescending();
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
});
}
public override int SaveChanges()
{
NormalizeTrackedArrays();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
NormalizeTrackedArrays();
return base.SaveChangesAsync(cancellationToken);
}
private void NormalizeTrackedArrays()
{
foreach (var entry in ChangeTracker.Entries<SpineEntity>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Entity.EvidenceIds = NormalizeEvidenceIds(entry.Entity.EvidenceIds);
}
}
foreach (var entry in ChangeTracker.Entries<TrustAnchorEntity>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Entity.AllowedKeyIds = NormalizeKeyIds(entry.Entity.AllowedKeyIds);
}
}
}
private static string[] NormalizeEvidenceIds(string[] evidenceIds)
{
if (evidenceIds.Length == 0)
{
return evidenceIds;
}
return evidenceIds
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
}
private static string[] NormalizeKeyIds(string[] keyIds)
{
if (keyIds.Length == 0)
{
return keyIds;
}
return keyIds
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}

View File

@@ -59,7 +59,8 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
private readonly ILogger<TrustAnchorMatcher> _logger;
// Cache compiled regex patterns
private readonly Dictionary<string, Regex> _patternCache = new();
private const int MaxRegexCacheSize = 1024;
private readonly Dictionary<string, Regex> _patternCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _cacheLock = new();
public TrustAnchorMatcher(
@@ -92,7 +93,7 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
{
var specificity = CalculateSpecificity(anchor.PurlPattern);
if (bestMatch == null || specificity > bestMatch.Specificity)
if (IsBetterMatch(anchor, specificity, bestMatch))
{
bestMatch = new TrustAnchorMatchResult
{
@@ -190,6 +191,11 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
var regexPattern = ConvertGlobToRegex(pattern);
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
if (_patternCache.Count >= MaxRegexCacheSize)
{
_patternCache.Clear();
}
_patternCache[pattern] = regex;
return regex;
}
@@ -284,4 +290,36 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
}
return true;
}
private static bool IsBetterMatch(
TrustAnchorEntity candidate,
int specificity,
TrustAnchorMatchResult? bestMatch)
{
if (bestMatch == null)
{
return true;
}
if (specificity != bestMatch.Specificity)
{
return specificity > bestMatch.Specificity;
}
var candidatePattern = candidate.PurlPattern ?? string.Empty;
var bestPattern = bestMatch.MatchedPattern ?? string.Empty;
if (candidatePattern.Length != bestPattern.Length)
{
return candidatePattern.Length > bestPattern.Length;
}
var patternCompare = string.Compare(candidatePattern, bestPattern, StringComparison.OrdinalIgnoreCase);
if (patternCompare != 0)
{
return patternCompare < 0;
}
return candidate.AnchorId.CompareTo(bestMatch.Anchor.AnchorId) < 0;
}
}

View File

@@ -7,6 +7,7 @@
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Attestor.Persistence</RootNamespace>
<Description>Proof chain persistence layer with Entity Framework Core and PostgreSQL support.</Description>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0060-M | DONE | Maintainability audit for StellaOps.Attestor.Persistence. |
| AUDIT-0060-T | DONE | Test coverage audit for StellaOps.Attestor.Persistence. |
| AUDIT-0060-A | TODO | Pending approval for changes. |
| AUDIT-0060-A | DONE | Applied defaults, normalization, deterministic matching, perf script, tests. |

View File

@@ -19,10 +19,17 @@ public sealed class AuditHashLogger
{
private readonly ILogger<AuditHashLogger> _logger;
private readonly bool _enableDetailedLogging;
private readonly TimeProvider _timeProvider;
public AuditHashLogger(ILogger<AuditHashLogger> logger, bool enableDetailedLogging = false)
: this(logger, TimeProvider.System, enableDetailedLogging)
{
}
public AuditHashLogger(ILogger<AuditHashLogger> logger, TimeProvider timeProvider, bool enableDetailedLogging = false)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_enableDetailedLogging = enableDetailedLogging;
}
@@ -91,7 +98,7 @@ public sealed class AuditHashLogger
RawSizeBytes = rawBytes.Length,
CanonicalSizeBytes = canonicalBytes.Length,
HashesMatch = rawHash.Equals(canonicalHash, StringComparison.Ordinal),
Timestamp = DateTimeOffset.UtcNow,
Timestamp = _timeProvider.GetUtcNow(),
CorrelationId = correlationId
};

View File

@@ -1,5 +1,6 @@
namespace StellaOps.Attestor.ProofChain.Generators;
using System.Text;
using System.Text.Json;
using StellaOps.Attestor.ProofChain.Models;
using StellaOps.Canonical.Json;
@@ -26,11 +27,33 @@ public sealed class BackportProofGenerator
string fixedVersion,
DateTimeOffset advisoryDate,
JsonDocument advisoryData)
{
return FromDistroAdvisory(
cveId,
packagePurl,
advisorySource,
advisoryId,
fixedVersion,
advisoryDate,
advisoryData,
TimeProvider.System);
}
public static ProofBlob FromDistroAdvisory(
string cveId,
string packagePurl,
string advisorySource,
string advisoryId,
string fixedVersion,
DateTimeOffset advisoryDate,
JsonDocument advisoryData,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:distro:{advisorySource}:{advisoryId}";
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(advisoryData));
var dataElement = advisoryData.RootElement.Clone();
var dataHash = ComputeDataHash(advisoryData.RootElement.GetRawText());
var evidence = new ProofEvidence
{
@@ -38,7 +61,7 @@ public sealed class BackportProofGenerator
Type = EvidenceType.DistroAdvisory,
Source = advisorySource,
Timestamp = advisoryDate,
Data = advisoryData,
Data = dataElement,
DataHash = dataHash
};
@@ -47,12 +70,12 @@ public sealed class BackportProofGenerator
ProofId = "", // Will be computed
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = "distro_advisory_tier1",
Confidence = 0.98, // Highest confidence - authoritative source
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -66,12 +89,22 @@ public sealed class BackportProofGenerator
string packagePurl,
ChangelogEntry changelogEntry,
string changelogSource)
{
return FromChangelog(cveId, packagePurl, changelogEntry, changelogSource, TimeProvider.System);
}
public static ProofBlob FromChangelog(
string cveId,
string packagePurl,
ChangelogEntry changelogEntry,
string changelogSource,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:changelog:{changelogSource}:{changelogEntry.Version}";
var changelogData = JsonDocument.Parse(JsonSerializer.Serialize(changelogEntry));
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(changelogData));
var changelogData = SerializeToElement(changelogEntry, out var changelogBytes);
var dataHash = ComputeDataHash(changelogBytes);
var evidence = new ProofEvidence
{
@@ -88,12 +121,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = "changelog_mention_tier2",
Confidence = changelogEntry.Confidence,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -106,12 +139,21 @@ public sealed class BackportProofGenerator
string cveId,
string packagePurl,
PatchHeaderParseResult patchResult)
{
return FromPatchHeader(cveId, packagePurl, patchResult, TimeProvider.System);
}
public static ProofBlob FromPatchHeader(
string cveId,
string packagePurl,
PatchHeaderParseResult patchResult,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:patch_header:{patchResult.PatchFilePath}";
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchResult));
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
var patchData = SerializeToElement(patchResult, out var patchBytes);
var dataHash = ComputeDataHash(patchBytes);
var evidence = new ProofEvidence
{
@@ -128,12 +170,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = "patch_header_tier3",
Confidence = patchResult.Confidence,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -147,12 +189,22 @@ public sealed class BackportProofGenerator
string packagePurl,
PatchSignature patchSig,
bool exactMatch)
{
return FromPatchSignature(cveId, packagePurl, patchSig, exactMatch, TimeProvider.System);
}
public static ProofBlob FromPatchSignature(
string cveId,
string packagePurl,
PatchSignature patchSig,
bool exactMatch,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:hunksig:{patchSig.CommitSha}";
var patchData = JsonDocument.Parse(JsonSerializer.Serialize(patchSig));
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(patchData));
var patchData = SerializeToElement(patchSig, out var patchBytes);
var dataHash = ComputeDataHash(patchBytes);
var evidence = new ProofEvidence
{
@@ -172,12 +224,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = exactMatch ? "hunksig_exact_tier3" : "hunksig_fuzzy_tier3",
Confidence = confidence,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -193,19 +245,39 @@ public sealed class BackportProofGenerator
string fingerprintValue,
JsonDocument fingerprintData,
double confidence)
{
return FromBinaryFingerprint(
cveId,
packagePurl,
fingerprintMethod,
fingerprintValue,
fingerprintData,
confidence,
TimeProvider.System);
}
public static ProofBlob FromBinaryFingerprint(
string cveId,
string packagePurl,
string fingerprintMethod,
string fingerprintValue,
JsonDocument fingerprintData,
double confidence,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:binary:{fingerprintMethod}:{fingerprintValue}";
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(fingerprintData));
var dataElement = fingerprintData.RootElement.Clone();
var dataHash = ComputeDataHash(fingerprintData.RootElement.GetRawText());
var evidence = new ProofEvidence
{
EvidenceId = evidenceId,
Type = EvidenceType.BinaryFingerprint,
Source = fingerprintMethod,
Timestamp = DateTimeOffset.UtcNow,
Data = fingerprintData,
Timestamp = timeProvider.GetUtcNow(),
Data = dataElement,
DataHash = dataHash
};
@@ -214,12 +286,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = $"binary_{fingerprintMethod}_tier4",
Confidence = confidence,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -232,6 +304,15 @@ public sealed class BackportProofGenerator
string cveId,
string packagePurl,
IReadOnlyList<ProofEvidence> evidences)
{
return CombineEvidence(cveId, packagePurl, evidences, TimeProvider.System);
}
public static ProofBlob CombineEvidence(
string cveId,
string packagePurl,
IReadOnlyList<ProofEvidence> evidences,
TimeProvider timeProvider)
{
if (evidences.Count == 0)
{
@@ -251,12 +332,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = evidences,
Method = method,
Confidence = confidence,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -270,19 +351,30 @@ public sealed class BackportProofGenerator
string packagePurl,
string reason,
JsonDocument versionData)
{
return NotAffected(cveId, packagePurl, reason, versionData, TimeProvider.System);
}
public static ProofBlob NotAffected(
string cveId,
string packagePurl,
string reason,
JsonDocument versionData,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
var evidenceId = $"evidence:version_comparison:{cveId}";
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(versionData));
var dataElement = versionData.RootElement.Clone();
var dataHash = ComputeDataHash(versionData.RootElement.GetRawText());
var evidence = new ProofEvidence
{
EvidenceId = evidenceId,
Type = EvidenceType.VersionComparison,
Source = "version_comparison",
Timestamp = DateTimeOffset.UtcNow,
Data = versionData,
Timestamp = timeProvider.GetUtcNow(),
Data = dataElement,
DataHash = dataHash
};
@@ -291,12 +383,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.NotAffected,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = new[] { evidence },
Method = reason,
Confidence = 0.95,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -309,6 +401,15 @@ public sealed class BackportProofGenerator
string cveId,
string packagePurl,
string reason)
{
return Vulnerable(cveId, packagePurl, reason, TimeProvider.System);
}
public static ProofBlob Vulnerable(
string cveId,
string packagePurl,
string reason,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
@@ -318,12 +419,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.Vulnerable,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = Array.Empty<ProofEvidence>(),
Method = reason,
Confidence = 0.85, // Lower confidence - absence of evidence is not evidence of absence
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -337,6 +438,16 @@ public sealed class BackportProofGenerator
string packagePurl,
string reason,
IReadOnlyList<ProofEvidence> partialEvidences)
{
return Unknown(cveId, packagePurl, reason, partialEvidences, TimeProvider.System);
}
public static ProofBlob Unknown(
string cveId,
string packagePurl,
string reason,
IReadOnlyList<ProofEvidence> partialEvidences,
TimeProvider timeProvider)
{
var subjectId = $"{cveId}:{packagePurl}";
@@ -345,12 +456,12 @@ public sealed class BackportProofGenerator
ProofId = "",
SubjectId = subjectId,
Type = ProofBlobType.Unknown,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
Evidences = partialEvidences,
Method = reason,
Confidence = 0.0,
ToolVersion = ToolVersion,
SnapshotId = GenerateSnapshotId()
SnapshotId = GenerateSnapshotId(timeProvider)
};
return ProofHashing.WithHash(proof);
@@ -418,9 +529,27 @@ public sealed class BackportProofGenerator
return $"multi_tier_combined_{types.Count}";
}
private static string GenerateSnapshotId()
private static string GenerateSnapshotId(TimeProvider timeProvider)
{
// Snapshot ID format: YYYYMMDD-HHMMSS-UTC
return DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-UTC";
return timeProvider.GetUtcNow().ToString("yyyyMMdd-HHmmss") + "-UTC";
}
private static JsonElement SerializeToElement<T>(T value, out byte[] jsonBytes)
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value);
using var document = JsonDocument.Parse(jsonBytes);
return document.RootElement.Clone();
}
private static string ComputeDataHash(ReadOnlySpan<byte> jsonBytes)
{
return CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(jsonBytes));
}
private static string ComputeDataHash(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
return ComputeDataHash(bytes);
}
}

View File

@@ -20,6 +20,17 @@ public sealed class BinaryFingerprintEvidenceGenerator
{
private const string ToolId = "stellaops.binaryindex";
private const string ToolVersion = "1.0.0";
private readonly TimeProvider _timeProvider;
public BinaryFingerprintEvidenceGenerator()
: this(TimeProvider.System)
{
}
public BinaryFingerprintEvidenceGenerator(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Generate a proof segment from binary vulnerability findings.
@@ -28,8 +39,8 @@ public sealed class BinaryFingerprintEvidenceGenerator
{
ArgumentNullException.ThrowIfNull(predicate);
var predicateJson = JsonSerializer.SerializeToDocument(predicate, GetJsonOptions());
var dataHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(predicateJson));
var predicateJson = SerializeToElement(predicate, GetJsonOptions(), out var predicateBytes);
var dataHash = CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(predicateBytes));
// Create subject ID from binary key and scan context
var subjectId = $"binary:{predicate.BinaryIdentity.BinaryKey}";
@@ -42,15 +53,15 @@ public sealed class BinaryFingerprintEvidenceGenerator
var evidences = new List<ProofEvidence>();
foreach (var match in predicate.Matches)
{
var matchData = JsonSerializer.SerializeToDocument(match, GetJsonOptions());
var matchHash = CanonJson.Sha256Prefixed(CanonJson.Canonicalize(matchData));
var matchData = SerializeToElement(match, GetJsonOptions(), out var matchBytes);
var matchHash = CanonJson.Sha256Prefixed(CanonJson.CanonicalizeParsedJson(matchBytes));
evidences.Add(new ProofEvidence
{
EvidenceId = $"evidence:binary:{predicate.BinaryIdentity.BinaryKey}:{match.CveId}",
Type = EvidenceType.BinaryFingerprint,
Source = match.Method,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = _timeProvider.GetUtcNow(),
Data = matchData,
DataHash = matchHash
});
@@ -65,7 +76,7 @@ public sealed class BinaryFingerprintEvidenceGenerator
ProofId = "", // Will be computed by ProofHashing.WithHash
SubjectId = subjectId,
Type = proofType,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = _timeProvider.GetUtcNow(),
Evidences = evidences,
Method = "binary_fingerprint_evidence",
Confidence = confidence,
@@ -176,9 +187,19 @@ public sealed class BinaryFingerprintEvidenceGenerator
return totalWeight > 0 ? Math.Min(weightedSum / totalWeight, 0.98) : 0.0;
}
private static string GenerateSnapshotId()
private string GenerateSnapshotId()
{
return DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss") + "-UTC";
return _timeProvider.GetUtcNow().ToString("yyyyMMdd-HHmmss") + "-UTC";
}
private static JsonElement SerializeToElement<T>(
T value,
JsonSerializerOptions options,
out byte[] jsonBytes)
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(value, options);
using var document = JsonDocument.Parse(jsonBytes);
return document.RootElement.Clone();
}
private static JsonSerializerOptions GetJsonOptions()

View File

@@ -111,24 +111,26 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
}
/// <inheritdoc />
public async Task<SchemaValidationResult> ValidatePredicateAsync(
public Task<SchemaValidationResult> ValidatePredicateAsync(
string json,
string predicateType,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (!HasSchema(predicateType))
{
return SchemaValidationResult.Failure(new SchemaValidationError
return Task.FromResult(SchemaValidationResult.Failure(new SchemaValidationError
{
Path = "/",
Message = $"No schema registered for predicate type: {predicateType}",
Keyword = "predicateType"
});
}));
}
try
{
var document = JsonDocument.Parse(json);
using var document = JsonDocument.Parse(json);
// TODO: Implement actual JSON Schema validation
// For now, do basic structural checks
@@ -174,27 +176,29 @@ public sealed class PredicateSchemaValidator : IJsonSchemaValidator
}
return errors.Count > 0
? SchemaValidationResult.Failure(errors.ToArray())
: SchemaValidationResult.Success();
? Task.FromResult(SchemaValidationResult.Failure(errors.ToArray()))
: Task.FromResult(SchemaValidationResult.Success());
}
catch (JsonException ex)
{
return SchemaValidationResult.Failure(new SchemaValidationError
return Task.FromResult(SchemaValidationResult.Failure(new SchemaValidationError
{
Path = "/",
Message = $"Invalid JSON: {ex.Message}",
Keyword = "format"
});
}));
}
}
/// <inheritdoc />
public async Task<SchemaValidationResult> ValidateStatementAsync<T>(
public Task<SchemaValidationResult> ValidateStatementAsync<T>(
T statement,
CancellationToken ct = default) where T : Statements.InTotoStatement
{
ct.ThrowIfCancellationRequested();
var json = System.Text.Json.JsonSerializer.Serialize(statement);
return await ValidatePredicateAsync(json, statement.PredicateType, ct);
return ValidatePredicateAsync(json, statement.PredicateType, ct);
}
/// <inheritdoc />

View File

@@ -197,53 +197,119 @@ public sealed class Rfc8785JsonCanonicalizer : IJsonCanonicalizer
private static void WriteNumber(Utf8JsonWriter writer, JsonElement element)
{
var raw = element.GetRawText();
if (!double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
double.IsNaN(value) ||
double.IsInfinity(value))
writer.WriteRawValue(NormalizeNumberString(raw), skipInputValidation: true);
}
private static string NormalizeNumberString(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
throw new FormatException("Invalid JSON number.");
}
var index = 0;
var negative = raw[index] == '-';
if (negative)
{
index++;
}
var intStart = index;
while (index < raw.Length && char.IsDigit(raw[index]))
{
index++;
}
if (index == intStart)
{
throw new FormatException($"Invalid JSON number: '{raw}'.");
}
if (value == 0d)
var intPart = raw[intStart..index];
var fracPart = string.Empty;
if (index < raw.Length && raw[index] == '.')
{
writer.WriteRawValue("0", skipInputValidation: true);
return;
index++;
var fracStart = index;
while (index < raw.Length && char.IsDigit(raw[index]))
{
index++;
}
if (index == fracStart)
{
throw new FormatException($"Invalid JSON number: '{raw}'.");
}
fracPart = raw[fracStart..index];
}
var formatted = value.ToString("R", CultureInfo.InvariantCulture);
writer.WriteRawValue(NormalizeExponent(formatted), skipInputValidation: true);
var exponent = 0;
if (index < raw.Length && (raw[index] == 'e' || raw[index] == 'E'))
{
index++;
var expNegative = false;
if (index < raw.Length && (raw[index] == '+' || raw[index] == '-'))
{
expNegative = raw[index] == '-';
index++;
}
var expStart = index;
while (index < raw.Length && char.IsDigit(raw[index]))
{
index++;
}
if (index == expStart)
{
throw new FormatException($"Invalid JSON number: '{raw}'.");
}
var expValue = int.Parse(raw[expStart..index], CultureInfo.InvariantCulture);
exponent = expNegative ? -expValue : expValue;
}
if (index != raw.Length)
{
throw new FormatException($"Invalid JSON number: '{raw}'.");
}
var digits = (intPart + fracPart).TrimStart('0');
if (digits.Length == 0)
{
return "0";
}
var decimalExponent = exponent - fracPart.Length;
var normalized = decimalExponent >= 0
? digits + new string('0', decimalExponent)
: InsertDecimalPoint(digits, decimalExponent);
return negative ? "-" + normalized : normalized;
}
private static string NormalizeExponent(string formatted)
private static string InsertDecimalPoint(string digits, int decimalExponent)
{
var e = formatted.IndexOfAny(['E', 'e']);
if (e < 0)
var position = digits.Length + decimalExponent;
if (position > 0)
{
return formatted;
var integerPart = digits[..position].TrimStart('0');
if (integerPart.Length == 0)
{
integerPart = "0";
}
var fractionalPart = digits[position..].TrimEnd('0');
if (fractionalPart.Length == 0)
{
return integerPart;
}
return $"{integerPart}.{fractionalPart}";
}
var mantissa = formatted[..e];
var exponent = formatted[(e + 1)..];
if (string.IsNullOrWhiteSpace(exponent))
{
return mantissa;
}
var sign = string.Empty;
if (exponent[0] is '+' or '-')
{
sign = exponent[0] == '-' ? "-" : string.Empty;
exponent = exponent[1..];
}
exponent = exponent.TrimStart('0');
if (exponent.Length == 0)
{
// 1e0 -> 1
return mantissa;
}
return $"{mantissa}e{sign}{exponent}";
var zeros = new string('0', -position);
var fraction = (zeros + digits).TrimEnd('0');
return $"0.{fraction}";
}
}

View File

@@ -30,13 +30,14 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
throw new ArgumentException("At least one leaf is required.", nameof(leafValues));
}
var sortedLeaves = SortLeaves(leafValues);
var levels = new List<IReadOnlyList<byte[]>>();
// Level 0: Hash all leaf values
var leafHashes = new List<byte[]>(PadToPowerOfTwo(leafValues.Count));
for (var i = 0; i < leafValues.Count; i++)
var leafHashes = new List<byte[]>(PadToPowerOfTwo(sortedLeaves.Count));
for (var i = 0; i < sortedLeaves.Count; i++)
{
leafHashes.Add(SHA256.HashData(leafValues[i].Span));
leafHashes.Add(SHA256.HashData(sortedLeaves[i].Span));
}
// Pad with duplicate of last leaf hash (deterministic)
@@ -149,6 +150,49 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
return currentHash.AsSpan().SequenceEqual(expectedRoot);
}
private static IReadOnlyList<ReadOnlyMemory<byte>> SortLeaves(IReadOnlyList<ReadOnlyMemory<byte>> leaves)
{
if (leaves.Count <= 1)
{
return leaves;
}
var indexed = new List<(ReadOnlyMemory<byte> Value, int Index)>(leaves.Count);
for (var i = 0; i < leaves.Count; i++)
{
indexed.Add((leaves[i], i));
}
indexed.Sort(static (left, right) =>
{
var comparison = CompareBytes(left.Value.Span, right.Value.Span);
return comparison != 0 ? comparison : left.Index.CompareTo(right.Index);
});
var ordered = new ReadOnlyMemory<byte>[indexed.Count];
for (var i = 0; i < indexed.Count; i++)
{
ordered[i] = indexed[i].Value;
}
return ordered;
}
private static int CompareBytes(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
{
var min = Math.Min(left.Length, right.Length);
for (var i = 0; i < min; i++)
{
var diff = left[i].CompareTo(right[i]);
if (diff != 0)
{
return diff;
}
}
return left.Length.CompareTo(right.Length);
}
private static int PadToPowerOfTwo(int count)
{
var power = 1;
@@ -168,4 +212,3 @@ public sealed class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
}
}

View File

@@ -70,7 +70,7 @@ public sealed record ProofEvidence
public required EvidenceType Type { get; init; }
public required string Source { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required JsonDocument Data { get; init; }
public required JsonElement Data { get; init; }
public required string DataHash { get; init; }
}

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0062-M | DONE | Maintainability audit for StellaOps.Attestor.ProofChain. |
| AUDIT-0062-T | DONE | Test coverage audit for StellaOps.Attestor.ProofChain. |
| AUDIT-0062-A | TODO | Pending approval for changes. |
| AUDIT-0062-A | DONE | Applied determinism, time providers, canonicalization, schema validation, tests. |

View File

@@ -1,6 +1,6 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Attestor.ProofChain.Json;
namespace StellaOps.Attestor.StandardPredicates;
@@ -10,6 +10,8 @@ namespace StellaOps.Attestor.StandardPredicates;
/// </summary>
public static class JsonCanonicalizer
{
private static readonly Rfc8785JsonCanonicalizer Canonicalizer = new();
/// <summary>
/// Canonicalize JSON according to RFC 8785.
/// </summary>
@@ -17,11 +19,14 @@ public static class JsonCanonicalizer
/// <returns>Canonical JSON (minified, lexicographically sorted keys, stable number format)</returns>
public static string Canonicalize(string json)
{
var node = JsonNode.Parse(json);
if (node == null)
return "null";
if (json is null)
{
throw new ArgumentNullException(nameof(json));
}
return CanonicalizeNode(node);
var bytes = Encoding.UTF8.GetBytes(json);
var canonical = Canonicalizer.Canonicalize(bytes);
return Encoding.UTF8.GetString(canonical);
}
/// <summary>
@@ -32,114 +37,4 @@ public static class JsonCanonicalizer
var json = element.GetRawText();
return Canonicalize(json);
}
private static string CanonicalizeNode(JsonNode node)
{
switch (node)
{
case JsonObject obj:
return CanonicalizeObject(obj);
case JsonArray arr:
return CanonicalizeArray(arr);
case JsonValue val:
return CanonicalizeValue(val);
default:
return "null";
}
}
private static string CanonicalizeObject(JsonObject obj)
{
var sb = new StringBuilder();
sb.Append('{');
var sortedKeys = obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal);
var first = true;
foreach (var key in sortedKeys)
{
if (!first)
sb.Append(',');
first = false;
// Escape key according to JSON rules
sb.Append(JsonSerializer.Serialize(key));
sb.Append(':');
var value = obj[key];
if (value != null)
{
sb.Append(CanonicalizeNode(value));
}
else
{
sb.Append("null");
}
}
sb.Append('}');
return sb.ToString();
}
private static string CanonicalizeArray(JsonArray arr)
{
var sb = new StringBuilder();
sb.Append('[');
for (int i = 0; i < arr.Count; i++)
{
if (i > 0)
sb.Append(',');
var item = arr[i];
if (item != null)
{
sb.Append(CanonicalizeNode(item));
}
else
{
sb.Append("null");
}
}
sb.Append(']');
return sb.ToString();
}
private static string CanonicalizeValue(JsonValue val)
{
// Let System.Text.Json handle proper escaping and number formatting
var jsonElement = JsonSerializer.SerializeToElement(val);
switch (jsonElement.ValueKind)
{
case JsonValueKind.String:
return JsonSerializer.Serialize(jsonElement.GetString());
case JsonValueKind.Number:
// Use ToString to get deterministic number representation
var number = jsonElement.GetDouble();
// Check if it's actually an integer
if (number == Math.Floor(number) && number >= long.MinValue && number <= long.MaxValue)
{
return jsonElement.GetInt64().ToString();
}
return number.ToString("G17"); // Full precision, no trailing zeros
case JsonValueKind.True:
return "true";
case JsonValueKind.False:
return "false";
case JsonValueKind.Null:
return "null";
default:
return JsonSerializer.Serialize(jsonElement);
}
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -173,15 +175,15 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
}
}
private Dictionary<string, string> ExtractMetadata(JsonElement payload)
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload)
{
var metadata = new Dictionary<string, string>();
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal);
if (payload.TryGetProperty("specVersion", out var specVersion))
metadata["specVersion"] = specVersion.GetString() ?? "";
if (payload.TryGetProperty("version", out var version))
metadata["version"] = version.GetInt32().ToString();
metadata["version"] = ReadVersionValue(version);
if (payload.TryGetProperty("serialNumber", out var serialNumber))
metadata["serialNumber"] = serialNumber.GetString() ?? "";
@@ -217,4 +219,15 @@ public sealed class CycloneDxPredicateParser : IPredicateParser
return metadata;
}
private static string ReadVersionValue(JsonElement version)
{
return version.ValueKind switch
{
JsonValueKind.Number when version.TryGetInt32(out var numeric) => numeric.ToString(CultureInfo.InvariantCulture),
JsonValueKind.Number => version.GetDouble().ToString(CultureInfo.InvariantCulture),
JsonValueKind.String => version.GetString() ?? "",
_ => ""
};
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
@@ -164,9 +166,9 @@ public sealed class SlsaProvenancePredicateParser : IPredicateParser
}
}
private Dictionary<string, string> ExtractMetadata(JsonElement payload)
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload)
{
var metadata = new Dictionary<string, string>();
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal);
// Extract build definition metadata
if (payload.TryGetProperty("buildDefinition", out var buildDef))
@@ -253,7 +255,7 @@ public sealed class SlsaProvenancePredicateParser : IPredicateParser
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? "",
JsonValueKind.Number => element.GetDouble().ToString(),
JsonValueKind.Number => element.GetDouble().ToString(CultureInfo.InvariantCulture),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -198,9 +199,9 @@ public sealed class SpdxPredicateParser : IPredicateParser
}
}
private Dictionary<string, string> ExtractMetadata(JsonElement payload, string version)
private IReadOnlyDictionary<string, string> ExtractMetadata(JsonElement payload, string version)
{
var metadata = new Dictionary<string, string>
var metadata = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["spdxVersion"] = version
};

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace StellaOps.Attestor.StandardPredicates;
/// <summary>
@@ -49,7 +51,8 @@ public sealed record PredicateMetadata
/// <summary>
/// Additional properties extracted from the predicate.
/// </summary>
public Dictionary<string, string> Properties { get; init; } = new();
public IReadOnlyDictionary<string, string> Properties { get; init; }
= new SortedDictionary<string, string>(StringComparer.Ordinal);
}
/// <summary>

View File

@@ -37,7 +37,19 @@ public sealed class StandardPredicateRegistry : IStandardPredicateRegistry
/// <returns>True if parser found, false otherwise</returns>
public bool TryGetParser(string predicateType, [NotNullWhen(true)] out IPredicateParser? parser)
{
return _parsers.TryGetValue(predicateType, out parser);
if (_parsers.TryGetValue(predicateType, out parser))
{
return true;
}
var normalized = NormalizePredicateType(predicateType);
if (normalized != null && _parsers.TryGetValue(normalized, out parser))
{
return true;
}
parser = null;
return false;
}
/// <summary>
@@ -51,4 +63,24 @@ public sealed class StandardPredicateRegistry : IStandardPredicateRegistry
.ToList()
.AsReadOnly();
}
private static string? NormalizePredicateType(string predicateType)
{
if (string.IsNullOrWhiteSpace(predicateType))
{
return null;
}
if (predicateType.StartsWith("https://cyclonedx.org/bom/", StringComparison.OrdinalIgnoreCase))
{
return "https://cyclonedx.org/bom";
}
if (predicateType.StartsWith("https://spdx.org/spdxdocs/spdx-v2.", StringComparison.OrdinalIgnoreCase))
{
return "https://spdx.dev/Document";
}
return null;
}
}

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0064-M | DONE | Maintainability audit for StellaOps.Attestor.StandardPredicates. |
| AUDIT-0064-T | DONE | Test coverage audit for StellaOps.Attestor.StandardPredicates. |
| AUDIT-0064-A | TODO | Pending approval for changes. |
| AUDIT-0064-A | DONE | Applied canonicalization, registry normalization, parser metadata fixes, tests. |

View File

@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Org.BouncyCastle.Crypto.Parameters;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.GraphRoot.Models;
using Xunit;
@@ -15,6 +16,9 @@ namespace StellaOps.Attestor.GraphRoot.Tests;
public class GraphRootAttestorTests
{
private static readonly TimeProvider FixedTimeProviderInstance =
new FixedTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
private readonly Mock<IMerkleRootComputer> _merkleComputerMock;
private readonly EnvelopeSignatureService _signatureService;
private readonly GraphRootAttestor _attestor;
@@ -28,12 +32,7 @@ public class GraphRootAttestorTests
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Returns(new byte[32]); // 32-byte hash
// Create a real test key for signing (need both private and public for Ed25519)
var privateKey = new byte[64]; // Ed25519 expanded private key is 64 bytes
var publicKey = new byte[32];
Random.Shared.NextBytes(privateKey);
Random.Shared.NextBytes(publicKey);
_testKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-key-id");
_testKey = CreateDeterministicKey("test-key-id");
_signatureService = new EnvelopeSignatureService();
@@ -41,7 +40,8 @@ public class GraphRootAttestorTests
_merkleComputerMock.Object,
_signatureService,
_ => _testKey,
NullLogger<GraphRootAttestor>.Instance);
NullLogger<GraphRootAttestor>.Instance,
timeProvider: FixedTimeProviderInstance);
}
[Trait("Category", TestCategories.Unit)]
@@ -170,6 +170,40 @@ public class GraphRootAttestorTests
Assert.Contains("sha256:params", digestStrings);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AttestAsync_IncludesEvidenceIdsInLeaves()
{
// Arrange
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = Array.Empty<string>(),
EdgeIds = Array.Empty<string>(),
PolicyDigest = "sha256:policy",
FeedsDigest = "sha256:feeds",
ToolchainDigest = "sha256:toolchain",
ParamsDigest = "sha256:params",
ArtifactDigest = "sha256:artifact",
EvidenceIds = new[] { "evidence-b", "evidence-a" }
};
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
_merkleComputerMock
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
.Returns(new byte[32]);
// Act
await _attestor.AttestAsync(request);
// Assert
Assert.NotNull(capturedLeaves);
var leafStrings = capturedLeaves.Select(l => System.Text.Encoding.UTF8.GetString(l.Span)).ToList();
Assert.Contains("evidence-a", leafStrings);
Assert.Contains("evidence-b", leafStrings);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AttestAsync_NullRequest_ThrowsArgumentNullException()
@@ -187,7 +221,8 @@ public class GraphRootAttestorTests
_merkleComputerMock.Object,
_signatureService,
_ => null,
NullLogger<GraphRootAttestor>.Instance);
NullLogger<GraphRootAttestor>.Instance,
timeProvider: FixedTimeProviderInstance);
var request = CreateValidRequest();
@@ -249,4 +284,32 @@ public class GraphRootAttestorTests
ArtifactDigest = "sha256:artifact345"
};
}
private static EnvelopeKey CreateDeterministicKey(string keyId)
{
var seed = new byte[32];
for (var i = 0; i < seed.Length; i++)
{
seed[i] = (byte)(i + 1);
}
var privateKeyParameters = new Ed25519PrivateKeyParameters(seed, 0);
var publicKeyParameters = privateKeyParameters.GeneratePublicKey();
var publicKey = publicKeyParameters.GetEncoded();
var privateKey = privateKeyParameters.GetEncoded();
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, keyId);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}

View File

@@ -63,6 +63,7 @@ public class GraphRootModelsTests
public void GraphRootPredicate_RequiredProperties_Set()
{
// Arrange & Act
var fixedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var predicate = new GraphRootPredicate
{
GraphType = "DependencyGraph",
@@ -79,7 +80,7 @@ public class GraphRootModelsTests
ParamsDigest = "sha256:pr"
},
CanonVersion = "stella:canon:v1",
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = fixedTime,
ComputedBy = "test",
ComputedByVersion = "1.0.0"
};
@@ -97,6 +98,7 @@ public class GraphRootModelsTests
public void GraphRootAttestation_HasCorrectDefaults()
{
// Arrange & Act
var fixedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var attestation = new GraphRootAttestation
{
Subject = new[]
@@ -123,7 +125,7 @@ public class GraphRootModelsTests
ParamsDigest = "sha256:pr"
},
CanonVersion = "v1",
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = fixedTime,
ComputedBy = "test",
ComputedByVersion = "1.0"
}

View File

@@ -18,6 +18,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Org.BouncyCastle.Crypto.Parameters;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Envelope;
@@ -33,16 +34,23 @@ namespace StellaOps.Attestor.GraphRoot.Tests;
/// </summary>
public class GraphRootPipelineIntegrationTests
{
private static readonly TimeProvider FixedTimeProviderInstance =
new FixedTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
#region Helpers
private static (EnvelopeKey Key, byte[] PublicKey) CreateTestKey()
{
// Generate a real Ed25519 key pair for testing
var privateKey = new byte[64]; // Ed25519 expanded private key
var publicKey = new byte[32];
Random.Shared.NextBytes(privateKey);
Random.Shared.NextBytes(publicKey);
var seed = new byte[32];
for (var i = 0; i < seed.Length; i++)
{
seed[i] = (byte)(i + 1);
}
var privateKeyParameters = new Ed25519PrivateKeyParameters(seed, 0);
var publicKeyParameters = privateKeyParameters.GeneratePublicKey();
var publicKey = publicKeyParameters.GetEncoded();
var privateKey = privateKeyParameters.GetEncoded();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-integration-key");
return (key, publicKey);
}
@@ -58,7 +66,8 @@ public class GraphRootPipelineIntegrationTests
_ => key,
NullLogger<GraphRootAttestor>.Instance,
rekorClient,
Options.Create(options ?? new GraphRootAttestorOptions()));
Options.Create(options ?? new GraphRootAttestorOptions()),
FixedTimeProviderInstance);
}
private static GraphRootAttestationRequest CreateRealisticRequest(
@@ -69,7 +78,7 @@ public class GraphRootPipelineIntegrationTests
var nodeIds = Enumerable.Range(1, nodeCount)
.Select(i =>
{
var content = $"node-{i}-content-{Guid.NewGuid()}";
var content = $"node-{i}-content";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexStringLower(hash)}";
})
@@ -102,7 +111,7 @@ public class GraphRootPipelineIntegrationTests
ToolchainDigest = toolchainDigest,
ParamsDigest = paramsDigest,
ArtifactDigest = artifactDigest,
EvidenceIds = [$"evidence-{Guid.NewGuid()}", $"evidence-{Guid.NewGuid()}"]
EvidenceIds = ["evidence-1", "evidence-2"]
};
}
@@ -219,7 +228,7 @@ public class GraphRootPipelineIntegrationTests
Uuid = "test-uuid-12345",
Index = 42,
Status = "included",
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
IntegratedTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds()
});
var options = new GraphRootAttestorOptions
@@ -348,8 +357,7 @@ public class GraphRootPipelineIntegrationTests
// Assert
Assert.False(verifyResult.IsValid);
Assert.Contains("Root mismatch", verifyResult.FailureReason);
Assert.NotEqual(verifyResult.ExpectedRoot, verifyResult.ComputedRoot);
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
@@ -375,7 +383,7 @@ public class GraphRootPipelineIntegrationTests
// Assert
Assert.False(verifyResult.IsValid);
Assert.Contains("Root mismatch", verifyResult.FailureReason);
Assert.Contains("Predicate edge IDs do not match", verifyResult.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
@@ -401,7 +409,7 @@ public class GraphRootPipelineIntegrationTests
// Assert
Assert.False(verifyResult.IsValid);
Assert.NotEqual(request.NodeIds.Count, verifyResult.NodeCount);
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
}
[Trait("Category", TestCategories.Unit)]
@@ -425,6 +433,7 @@ public class GraphRootPipelineIntegrationTests
// Assert
Assert.False(verifyResult.IsValid);
Assert.Contains("Predicate node IDs do not match", verifyResult.FailureReason);
}
#endregion
@@ -543,4 +552,16 @@ public class GraphRootPipelineIntegrationTests
}
#endregion
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}